diff --git a/CHANGELOG.md b/CHANGELOG.md index 7effb5ea7c0..afe54107002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ### Added - The web status API (`/api/status`) reports a `temperatures` block: the chip temperature on ESP32-S3 devices, the battery fuel gauge's temperature on boards that have one (such as the X3), and on the X4 Pro the e-ink panel controller's own sensor, sampled after a screen refresh at most once a minute. Sensors a board lacks are left out. +- Assign Library to power, long-press, button-chord, Home-button, or Quick Actions shortcuts to open the book list directly. +- Customize the top and bottom reader status bars separately, including item positions and progress bars, in EPUB, TXT, and XTC books. Each bar can be previewed where it appears while reading. - View a selected book's reading stats from its Library or File Browser action menu. - Library replaces Recent Books with a searchable book list, and adds various book metadata sort options. - Reset a book's reader settings from the in-reader Settings tab. @@ -17,6 +19,8 @@ - On devices with PSRAM (Sticky, X4 Pro), the OPDS browser downloads the next page of a catalog in the background while you browse, and keeps pages you have visited in memory, so Next page, Previous page and Back open without waiting on the server. - The web file manager's image preview has previous and next buttons, and the left and right arrow keys, to step through the images in the current folder. Other file types are skipped, and stepping wraps around at either end. The preview also shows the image's position in the folder, its pixel dimensions, its file size and, when the file list has one, its modified date. - The Settings > System footer shows the firmware's branch (the batch number, such as `b11`, for combined test builds) and commit, with `*` when built from uncommitted changes, under the version. The System list stops above the footer instead of running under it. The web status API reports the full branch, build number (the commit count unless the build sets `CROSSINK_BUILD_NUMBER`; left out for shallow checkouts) and UTC build time. +- In-reader menu for X3/X4/X4 Classic have been updated to a modified version of the in-reader menu for touch devices +- Chapter pages and book progress information is displayed in the frontlight drawer when in the reader for X4 Pro ### Changed @@ -36,9 +40,16 @@ - Far fewer SD card writes when saving your place: EPUB progress now alternates between two small slot files that are overwritten in place, so a save costs about 2 sector writes instead of roughly a dozen, and a save that would store the position already on the card (such as closing a book without turning a page) writes nothing. Your place is now saved every 30 page turns or 15 minutes of reading instead of every 10 pages or 5 minutes; leaving the book or putting the device to sleep still saves it immediately, so only a crash, reset or dead battery can lose more pages than before. A save interrupted by power loss falls back to the previous save. TXT and XTC progress and the Home reading percentage are also overwritten in place instead of being truncated and rewritten. - Firmware is about 32 KB smaller: wolfSSL no longer builds its debug trace messages in. Builds with `-DFREEINK_WOLFSSL_DEBUG` still include them. - The X4 Pro light-sleep firmware now uses the scalable TTF versions of Bitter and Lexend Deca, like the standard X4 Pro build, making it about 360 KB smaller. +- Text drawing resolves clipping and screen rotation once per glyph, reducing work when painting menus and book pages. +- Home reads saved EPUB progress and chapter metadata without opening or indexing the book, and stops saved-item checks after the first file. +- Optional EPUB background work yields immediately when rendering is busy, keeping input polling responsive. +- SD-card fonts share identical character lookup tables across styles, reducing memory use and repeated card reads. +- EPUB reader menus now share five tabs across devices. Button devices gain live font and margin previews, Reading Stats, and in-book transfer options. +- The on-screen keyboard now uses wider outlined keys with clearer spacing on touch and button devices. ### Fixed +- File Transfer choices no longer appear preselected when opened on a touch device. - On the X4 Pro and Sticky, the Home screen no longer stalls waiting for the SD card while the Library indexes in the background. - Small images that are scaled up, such as a short progressive JPEG, are now cached after the first draw instead of being decoded again on every screen refresh. - The serial log no longer reports errors for normal events: a missing image, section or dictionary cache file on first open, an outdated section cache being rebuilt, and a device without a clock chip or motion sensor. Log lines that were missing a line break now end cleanly, and SD, display, TLS and frontlight messages use the standard log format. @@ -131,7 +142,7 @@ - EPUB dictionary lookup can select an individual part of a hyphenated word. - Short Power-button frontlight and touchscreen shortcuts in EPUB books no longer run the configured long-press action. - Silent restarts now preserve the frontlight state instead of applying wake or schedule settings. -- The Home button now returns from Customize Status Bar to the previous menu instead of leaving the reader. +- The Home button now returns from Status Bars to the previous menu instead of leaving the reader. - OPDS book downloads can follow secure redirects without sharing catalog credentials with the download host. - Larger EPUB stylesheets work on PSRAM readers, including rules that hide duplicate images. - JPEG-heavy EPUBs can use PSRAM for decoding on supported readers, leaving internal memory available for reading. diff --git a/docs/data-cache.md b/docs/data-cache.md index 106cf7cdb7c..3ab80f4c1af 100644 --- a/docs/data-cache.md +++ b/docs/data-cache.md @@ -88,8 +88,14 @@ For binary file layout details, see [File Formats](./file-formats.md). ## Library -Library replaces the Recent Books screen. It reconciles the SD card on entry and -through the refresh icon in the Library header, reusing metadata for unchanged books. +Library replaces the Recent Books screen. + +The Library reconciles its index with the SD card on the first visit after boot, +after file changes in File Browser, and after leaving a download or file-transfer +screen. Ordinary return visits reuse the index. Failed or memory-limited scans are +retried on the next visit. The Library's refresh action always scans again; use it +if files were changed externally while the firmware stayed running. +Unchanged books reuse their cached metadata. **Settings > Display > Use Book Metadata** selects embedded EPUB titles and authors; disabling it uses filenames. TXT, Markdown and XTC files use filename fallbacks. CLX1 version 2 adds a first-name author permutation; older Library diff --git a/docs/reader-features.md b/docs/reader-features.md index da96360a7d1..adb66ca4e78 100644 --- a/docs/reader-features.md +++ b/docs/reader-features.md @@ -150,7 +150,7 @@ consistent when you change fonts, spacing, orientation, or indexing mode. To enable them: 1. Open the reader menu and select **Reader Options**. -2. Select **Customize Status Bar**. +2. Select **Status Bars**. 3. Toggle **Stable Page Numbers** on. The option appears only when the current EPUB contains valid CrossInk reference diff --git a/docs/user-guide.md b/docs/user-guide.md index 66fce9def0c..ef2d7558071 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -317,7 +317,8 @@ Book Cache** does not reset these choices. while a book is open, while leaving touch available in reader menus so you can turn it back on. -- **Customize Status Bar**: Configure the status bar displayed while reading: +- **Status Bars**: Configure the status bars displayed while reading: + - Top and bottom bars each have three left slots, one center slot, and three right slots. - Chapter Page Count - Show/Hide the current page in the chapter (ex: 5/25). Page count may change based on the font size and margins set. - Book Progress Percentage - Show/Hide the current percent progress in the book. - Percentage Format - Show book progress as `10%` (default), `10.1%`, or `10.12%`. @@ -333,7 +334,7 @@ in **Settings > Controls > Taps & Gestures**), tap the status-bar area while reading to show or hide the entire status bar for the current reading session. This tap is available while **Touch Reader Controls** is enabled. The quick toggle does not change the page layout or page breaks; tap the same status-bar -region again to restore a hidden bar. Use **Customize Status Bar** to choose +region again to restore a hidden bar. Use **Status Bars** to choose which status-bar items are shown. #### 3.6.3 Controls diff --git a/freeink-sdk b/freeink-sdk index 3c20ecf7871..838622edfd0 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit 3c20ecf78717ee791a0bc8852a04731079289745 +Subproject commit 838622edfd0393e36a791c01da41f01dfdfb0080 diff --git a/include/AppCapabilities.h b/include/AppCapabilities.h index 1ea28c41507..2bb711b9e2d 100644 --- a/include/AppCapabilities.h +++ b/include/AppCapabilities.h @@ -38,3 +38,6 @@ #error "CROSSINK_APP_CAP_USB_DRIVE must match FREEINK_CAP_USB_MSC" #endif #endif + +// Every button-only reader uses the full-screen menu and its bounded sample preview. +#define CROSSINK_APP_READER_SAMPLE_PREVIEW (!CROSSINK_APP_CAP_TOUCH) diff --git a/include/DateFormatting.h b/include/DateFormatting.h new file mode 100644 index 00000000000..53e080ee697 --- /dev/null +++ b/include/DateFormatting.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +// Format a known date using the persisted date-format values shared by settings and HalClock. +inline bool formatDateParts(char* buf, const size_t bufSize, const uint16_t year, const uint8_t month, + const uint8_t day, const uint8_t dateFormat, const char numericSeparator = '/') { + if (bufSize == 0 || month < 1 || month > 12 || day < 1 || day > 31) return false; + + static constexpr const char* monthNames[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + static constexpr const char* fullMonthNames[] = {"January", "February", "March", "April", + "May", "June", "July", "August", + "September", "October", "November", "December"}; + const char separator = numericSeparator == '.' || numericSeparator == '-' ? numericSeparator : '/'; + int length = 0; + switch (dateFormat) { + case 1: // Day month year, long + length = std::snprintf(buf, bufSize, "%02u %s %u", day, monthNames[month - 1], year); + break; + case 2: // Month day year, numeric + length = std::snprintf(buf, bufSize, "%02u%c%02u%c%u", month, separator, day, separator, year); + break; + case 3: // Day month year, numeric + length = std::snprintf(buf, bufSize, "%02u%c%02u%c%u", day, separator, month, separator, year); + break; + case 4: // Year month day, numeric + length = std::snprintf(buf, bufSize, "%u%c%02u%c%02u", year, separator, month, separator, day); + break; + case 5: // Month day, numeric + length = std::snprintf(buf, bufSize, "%02u%c%02u", month, separator, day); + break; + case 6: // Day month, numeric + length = std::snprintf(buf, bufSize, "%02u%c%02u", day, separator, month); + break; + case 7: // Month day, long + length = std::snprintf(buf, bufSize, "%s %02u", fullMonthNames[month - 1], day); + break; + case 8: // Day month, long + length = std::snprintf(buf, bufSize, "%02u %s", day, fullMonthNames[month - 1]); + break; + case 0: // Month day year, long + default: + length = std::snprintf(buf, bufSize, "%s %02u, %u", monthNames[month - 1], day, year); + break; + } + return length >= 0 && static_cast(length) < bufSize; +} diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index a073af5e352..266265f9c2b 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -224,10 +224,15 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) { void SdCardFont::freeStyleAll(PerStyle& s) { freeStyleMiniData(s); - delete[] s.fullIntervals; + // A shared table is owned by the style it was aliased from -- that style's own + // freeStyleAll() call frees it. Deleting it here too would double-free. + if (!s.intervalsShared) { + delete[] s.fullIntervals; + delete[] s.bmpIntervals; + } s.fullIntervals = nullptr; - delete[] s.bmpIntervals; s.bmpIntervals = nullptr; + s.intervalsShared = false; s.intervalsAreBmp16 = false; freeStyleKernLigatureData(s); s.present = false; @@ -678,6 +683,22 @@ bool SdCardFont::load(const char* path) { uint32_t expectedOffset = 0; uint32_t prevLast = 0; EpdUnicodeInterval iv{}; + + // Regular/bold/italic weights of the same family almost always cover the identical codepoint + // set, so a later style's table is usually a byte-for-byte copy of an earlier one's. Sharing + // it saves a full table per style, which on a broad CJK font is tens of KB, and saves the + // PEAK rather than just the residency: allocating first and de-duplicating afterwards still + // needs both tables at once, and that peak is what fails on a tight heap. + // The decision rides along with the validation read below -- every record is already being + // read here -- so it costs no second pass over the table and no buffer to hold one. + // A style stays a candidate only while its table has matched every record so far. + uint8_t shareCandidates = 0; + for (uint8_t k = 0; k < i; k++) { + const auto& owner = styles_[k]; + if (!owner.present || owner.header.intervalCount != s.header.intervalCount) continue; + if (!owner.bmpIntervals && !owner.fullIntervals) continue; + shareCandidates |= static_cast(1u << k); + } for (uint32_t j = 0; j < s.header.intervalCount; ++j) { if (file.read(reinterpret_cast(&iv), sizeof(iv)) != sizeof(iv)) { LOG_ERR("SDCF", "Failed to read interval %u for style %u", j, i); @@ -706,17 +727,49 @@ bool SdCardFont::load(const char* path) { if (iv.first > UINT16_MAX || iv.last > UINT16_MAX || iv.offset > UINT16_MAX) { canUseBmp16 = false; } + for (uint8_t k = 0; k < i && shareCandidates != 0; k++) { + if ((shareCandidates & (1u << k)) == 0) continue; + const auto& owner = styles_[k]; + // Compared by value, so an above-BMP record never equals a compact one and drops out here. + const bool same = owner.intervalsAreBmp16 + ? (owner.bmpIntervals[j].first == iv.first && owner.bmpIntervals[j].last == iv.last && + owner.bmpIntervals[j].offset == iv.offset) + : (owner.fullIntervals[j].first == iv.first && owner.fullIntervals[j].last == iv.last && + owner.fullIntervals[j].offset == iv.offset); + if (!same) shareCandidates &= static_cast(~(1u << k)); + } expectedOffset += span; prevLast = iv.last; } - if (!file.seekSet(s.intervalsFileOffset)) { + // Survived every record: alias the earlier style's table instead of allocating a copy. + // freeStyleAll() skips delete[] when intervalsShared is set, so only the owner frees. + for (uint8_t k = 0; k < i && shareCandidates != 0; k++) { + if ((shareCandidates & (1u << k)) == 0) continue; + auto& owner = styles_[k]; + // Identical content can still be held in the other resident form when the two styles + // disagree on glyph count; aliasing across forms would misread the table. + if (owner.intervalsAreBmp16 != canUseBmp16) continue; + s.bmpIntervals = owner.bmpIntervals; + s.fullIntervals = owner.fullIntervals; + s.intervalsAreBmp16 = owner.intervalsAreBmp16; + s.intervalsShared = true; + LOG_DBG("SDCF", "Style %u: sharing style %u's %u-interval table (%u B not allocated)", i, k, + s.header.intervalCount, + s.header.intervalCount * (canUseBmp16 ? 6u : static_cast(sizeof(EpdUnicodeInterval)))); + break; + } + + // Only the allocate-and-read path below needs the records again; a shared style is done. + if (!s.intervalsShared && !file.seekSet(s.intervalsFileOffset)) { LOG_ERR("SDCF", "Failed to seek back to intervals for style %u", i); freeAll(); return false; } - if (canUseBmp16) { + if (s.intervalsShared) { + // Aliased above; fall through to the stub/metadata setup without touching the table. + } else if (canUseBmp16) { s.bmpIntervals = new (std::nothrow) PerStyle::BmpInterval16[s.header.intervalCount]; if (!s.bmpIntervals) { LOG_ERR("SDCF", "Failed to allocate compact intervals for style %u", i); diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index d1045cbcf5b..4554fcc7f20 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -164,6 +164,11 @@ class SdCardFont { static_assert(sizeof(BmpInterval16) == 6, "BmpInterval16 must remain compact"); BmpInterval16* bmpIntervals = nullptr; bool intervalsAreBmp16 = false; + // True when bmpIntervals/fullIntervals above points at another style's table rather than + // this style's own allocation. Regular/bold/italic weights of the same family almost always + // cover the identical codepoint set, so a CJK font's multi-KB-per-style table is otherwise + // paid for once per style. Only the owning style frees it -- see freeStyleAll(). + bool intervalsShared = false; // Persistent kern-class + ligature tables (lazy-loaded on first prewarm). // The full kern MATRIX is NOT resident — on Literata-class fonts a single diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index bf49cc02236..7a20b845b39 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -488,8 +488,7 @@ class ContentsDocumentScanner final : public Print { } // namespace Epub::Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { - cachePath = cachePathForFilePath(this->filepath, cacheDir); - migrateLegacyCachePath(cacheDir); + cachePath = resolveCachePathForFilePath(this->filepath, cacheDir); } std::string Epub::cachePathForFilePath(const std::string& filepath, const std::string& cacheDir) { @@ -501,14 +500,15 @@ bool Epub::hasCache(const std::string& filepath, const std::string& cacheDir) { return BookMetadataCache::exists(cachePathForFilePath(filepath, cacheDir)); } -void Epub::migrateLegacyCachePath(const std::string& cacheDir) const { +std::string Epub::resolveCachePathForFilePath(const std::string& filepath, const std::string& cacheDir) { + const std::string cachePath = cachePathForFilePath(filepath, cacheDir); if (Storage.exists(cachePath.c_str())) { - return; + return cachePath; } const std::string legacyCachePath = legacyCachePathForFilePath(filepath, cacheDir); if (legacyCachePath == cachePath || !Storage.exists(legacyCachePath.c_str())) { - return; + return cachePath; } if (Storage.rename(legacyCachePath.c_str(), cachePath.c_str())) { @@ -516,6 +516,7 @@ void Epub::migrateLegacyCachePath(const std::string& cacheDir) const { } else { LOG_ERR("EBP", "Failed to migrate legacy EPUB cache: %s -> %s", legacyCachePath.c_str(), cachePath.c_str()); } + return cachePath; } bool Epub::findContentOpfFile(std::string* contentOpfFile) const { @@ -1135,9 +1136,6 @@ bool Epub::loadMetadata(std::string& title, std::string& author, const bool allo if (!metadataCache) { LOG_ERR("EBP", "Could not allocate metadata cache reader"); } - } else if (!allowCachedMetadata && !clearCache()) { - LOG_ERR("EBP", "Could not invalidate stale metadata cache"); - return false; } BookMetadataCache::BookMetadata metadata; diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index a07700c9a4c..a3be73dfc67 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -97,7 +97,6 @@ class Epub { Complete, }; - void migrateLegacyCachePath(const std::string& cacheDir) const; bool findContentOpfFile(std::string* contentOpfFile) const; bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, bool writeSpineEntries = true, bool collectCssFiles = true, bool metadataOnly = false, std::string* seriesIndex = nullptr); @@ -116,6 +115,10 @@ class Epub { explicit Epub(std::string filepath, const std::string& cacheDir); ~Epub() = default; static std::string cachePathForFilePath(const std::string& filepath, const std::string& cacheDir); + // Resolve the stable cache path and migrate an older hash-named directory if + // needed, without opening the EPUB or loading its metadata/location indexes. + static std::string resolveCachePathForFilePath(const std::string& filepath, const std::string& cacheDir); + // True when a metadata cache already exists for this book, i.e. load() will // hit the fast path instead of rebuilding. Cheap: no parsing, just a stat. static bool hasCache(const std::string& filepath, const std::string& cacheDir); @@ -124,8 +127,9 @@ class Epub { XLocationLoadMode xLocationLoadMode = XLocationLoadMode::Immediate, bool cacheCumulativeSpineSizes = false); // Title and author only, without building the spine/TOC/CSS/cover caches // load() does. Reuses an existing metadata cache when there is one unless - // allowCachedMetadata is false; that path invalidates a stale full cache and - // stops the OPF parse at , before the manifest. Used by the + // allowCachedMetadata is false. Cache invalidation is the caller's job, so + // metadata reads cannot delete per-book progress or settings. Parsing stops + // at , before the manifest. Used by the // Library index builder, which reads every EPUB on the card and cannot // afford a full load() per book. bool loadMetadata(std::string& title, std::string& author, bool allowCachedMetadata = true, diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 8e6348311a3..66a10daf425 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -16,6 +16,7 @@ #include #include "FontCacheManager.h" +#include "GlyphBitmap.h" namespace { @@ -848,111 +849,53 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode } const uint8_t* bitmap = renderer.getGlyphBitmap(fontData, glyph); + if (bitmap == nullptr) return; - if (bitmap != nullptr) { - // For Normal: outer loop advances screenY, inner loop advances screenX - // For Rotated: outer loop advances screenX, inner loop advances screenY (in reverse) - int outerBase, innerBase; - if constexpr (rotation == TextRotation::Rotated90CW) { - outerBase = cursorX + fontData->ascender - top; // screenX = outerBase + glyphY - innerBase = cursorY - left; // screenY = innerBase - glyphX - } else { - outerBase = cursorY - top; // screenY = outerBase + glyphY - innerBase = cursorX + left; // screenX = innerBase + glyphX - } - - // Hot path: an unclipped horizontal glyph writes straight into the target - // with incremental physical coordinates. Same pixel decisions as below. - if constexpr (rotation == TextRotation::None) { - GfxRenderer::GlyphTarget target; - if (renderer.glyphFastTarget(innerBase, outerBase, width, height, target)) { - int pixelPosition = 0; - for (int glyphY = 0; glyphY < height; glyphY++) { - int px = target.phyX + glyphY * target.rowDX; - int py = target.phyY + glyphY * target.rowDY; - for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++, px += target.colDX, py += target.colDY) { - bool draw = false; - bool state = false; - if (is2Bit) { - const uint8_t bmpVal = 3 - ((bitmap[pixelPosition >> 2] >> ((3 - (pixelPosition & 3)) * 2)) & 0x3); - if (renderMode == GfxRenderer::BW) { - draw = bmpVal < 3; - state = pixelState; - } else if (renderMode == GfxRenderer::GRAYSCALE_MSB) { - draw = bmpVal == 1 || bmpVal == 2; - } else if (renderMode == GfxRenderer::GRAYSCALE_LSB) { - draw = bmpVal == 1; - } - } else { - draw = ((bitmap[pixelPosition >> 3] >> (7 - (pixelPosition & 7))) & 1) != 0; - state = pixelState; - } - if (draw) target.set(px, py, state); - } - } - return; - } - } - - if (is2Bit) { - int pixelPosition = 0; - for (int glyphY = 0; glyphY < height; glyphY++) { - const int outerCoord = outerBase + glyphY; - for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) { - int screenX, screenY; - if constexpr (rotation == TextRotation::Rotated90CW) { - screenX = outerCoord; - screenY = innerBase - glyphX; - } else { - screenX = innerBase + glyphX; - screenY = outerCoord; - } - - const uint8_t byte = bitmap[pixelPosition >> 2]; - const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2; - // the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black - // we swap this to better match the way images and screen think about colors: - // 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white - const uint8_t bmpVal = 3 - ((byte >> bit_index) & 0x3); - - if (renderMode == GfxRenderer::BW && bmpVal < 3) { - // Black (also paints over the grays in BW mode) - renderer.drawPixel(screenX, screenY, pixelState); - } else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { - // Light gray (also mark the MSB if it's going to be a dark gray too) - // Dedicated X3 gray LUTs now provide proper 4-level gray on both devices - // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update - renderer.drawPixel(screenX, screenY, false); - } else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) { - // Dark gray - renderer.drawPixel(screenX, screenY, false); - } - } - } - } else { - int pixelPosition = 0; - for (int glyphY = 0; glyphY < height; glyphY++) { - const int outerCoord = outerBase + glyphY; - for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) { - int screenX, screenY; - if constexpr (rotation == TextRotation::Rotated90CW) { - screenX = outerCoord; - screenY = innerBase - glyphX; - } else { - screenX = innerBase + glyphX; - screenY = outerCoord; - } - - const uint8_t byte = bitmap[pixelPosition >> 3]; - const uint8_t bit_index = 7 - (pixelPosition & 7); - - if ((byte >> bit_index) & 1) { - renderer.drawPixel(screenX, screenY, pixelState); - } - } - } - } + // Logical placement of glyph pixel (0, 0) and the logical step for one move + // along each glyph axis. Rotated text runs glyph x up the screen and glyph + // y to the right. + glyphBitmap::Frame frame; + if constexpr (rotation == TextRotation::Rotated90CW) { + frame = {cursorX + fontData->ascender - top, cursorY - left, 0, -1, 1, 0}; + } else { + frame = {cursorX + left, cursorY - top, 1, 0, 0, 1}; } + renderer.drawGlyphBitmap(bitmap, width, height, frame, is2Bit, renderMode, pixelState); +} + +// Draw an unscaled glyph placed by a logical frame. Equivalent to calling +// drawPixel() for each ink pixel, but the clip test, orientation rotation, +// strip-band check and address math are resolved once per glyph rather than +// once per pixel. +void GfxRenderer::drawGlyphBitmap(const uint8_t* bitmap, const int width, const int height, + const glyphBitmap::Frame& frame, const bool twoBit, const RenderMode mode, + const bool state) const { + // Apply the logical clip rectangle before rotating, in glyph-local pixels. + glyphBitmap::Clip clip{0, 0, width, height}; + if (textClipActive_) { + glyphBitmap::clipToRect(frame, textClipLeft_, textClipTop_, textClipRight_, textClipBottom_, clip); + } + + // Writes go to the framebuffer, or to the strip scratch in tiled grayscale + // mode; getWriteOriginY()/getWriteRows() bound the rows that exist there. + glyphBitmap::Target target{getWriteTarget(), panelWidth, panelWidthBytes, getWriteOriginY(), getWriteRows(), {}}; + // Rotate the glyph origin once, then derive the two physical axes by + // rotating its neighbours along each logical axis. Together these encode + // the text rotation and the panel orientation as one orthogonal transform. + glyphBitmap::Frame& physical = target.frame; + rotateCoordinates(orientation, frame.x, frame.y, &physical.x, &physical.y, panelWidth, panelHeight); + int nextX, nextY; + rotateCoordinates(orientation, frame.x + frame.dxX, frame.y + frame.dxY, &nextX, &nextY, panelWidth, panelHeight); + physical.dxX = nextX - physical.x; + physical.dxY = nextY - physical.y; + rotateCoordinates(orientation, frame.x + frame.dyX, frame.y + frame.dyY, &nextX, &nextY, panelWidth, panelHeight); + physical.dyX = nextX - physical.x; + physical.dyY = nextY - physical.y; + + const glyphBitmap::Plane plane = mode == BW ? glyphBitmap::Plane::BW + : mode == GRAYSCALE_MSB ? glyphBitmap::Plane::GrayMSB + : glyphBitmap::Plane::GrayLSB; + glyphBitmap::draw(bitmap, width, height, twoBit, plane, state, target, clip); } // IMPORTANT: This function is in critical rendering path and is called for every pixel. Please keep it as simple and @@ -980,32 +923,6 @@ bool GfxRenderer::isPixelBlack(const int x, const int y) const { return (target[byteIndex] & (1 << bitPosition)) == 0; } -bool GfxRenderer::glyphFastTarget(const int x0, const int y0, const int w, const int h, GlyphTarget& out) const { - if (w <= 0 || h <= 0 || frameBuffer == nullptr) return false; - if (x0 < 0 || y0 < 0 || x0 + w > getScreenWidth() || y0 + h > getScreenHeight()) return false; - if (textClipActive_ && - (x0 < textClipLeft_ || x0 + w > textClipRight_ || y0 < textClipTop_ || y0 + h > textClipBottom_)) { - return false; - } - out.buf = frameBuffer; - if (_stripActive) { - // Only a whole-panel "strip" (the deferred-base AA planes) maps 1:1. - if (_stripY0 != 0 || _stripRows != static_cast(panelHeight)) return false; - out.buf = _stripBuf; - } - out.widthBytes = panelWidthBytes; - rotateCoordinates(orientation, x0, y0, &out.phyX, &out.phyY, panelWidth, panelHeight); - int nx = 0; - int ny = 0; - rotateCoordinates(orientation, x0 + 1, y0, &nx, &ny, panelWidth, panelHeight); - out.colDX = nx - out.phyX; - out.colDY = ny - out.phyY; - rotateCoordinates(orientation, x0, y0 + 1, &nx, &ny, panelWidth, panelHeight); - out.rowDX = nx - out.phyX; - out.rowDY = ny - out.phyY; - return true; -} - void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { if (textClipActive_ && (x < textClipLeft_ || x >= textClipRight_ || y < textClipTop_ || y >= textClipBottom_)) { return; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index c24fbd759c2..1bcf2c54af8 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -25,6 +25,10 @@ class SdCardFont; #include "Bitmap.h" +namespace glyphBitmap { +struct Frame; +} + // Color representation: uint8_t mapped to 4x4 Bayer matrix dithering levels // 0 = transparent, 1-16 = gray levels (white to black) enum Color : uint8_t { Clear = 0x00, White = 0x01, LightGray = 0x05, DarkGray = 0x0A, Black = 0x10 }; @@ -248,31 +252,9 @@ class GfxRenderer { // Drawing bool isPixelBlack(int x, int y) const; void drawPixel(int x, int y, bool state = true) const; - // Direct pixel target for the glyph hot path. drawPixel() re-derives the - // rotation, clip and strip state for every pixel; a glyph box that needs no - // clipping can instead step through physical coordinates incrementally. - struct GlyphTarget { - uint8_t* buf = nullptr; - int widthBytes = 0; - int phyX = 0; // physical position of the box's logical top-left pixel - int phyY = 0; - int colDX = 0; // physical step for logical +x - int colDY = 0; - int rowDX = 0; // physical step for logical +y - int rowDY = 0; - void set(const int x, const int y, const bool state) const { - uint8_t& b = buf[static_cast(y) * widthBytes + (x >> 3)]; - const auto mask = static_cast(0x80 >> (x & 7)); - if (state) { - b = static_cast(b & ~mask); // black clears the bit, as in drawPixel() - } else { - b = static_cast(b | mask); - } - } - }; - // False when the box needs per-pixel clipping (screen edge, text clip, or a - // partial grayscale strip); callers then fall back to drawPixel(). - bool glyphFastTarget(int x0, int y0, int w, int h, GlyphTarget& out) const; + // Unscaled glyphs share one clipped, orientation-aware rasterizer. + void drawGlyphBitmap(const uint8_t* bitmap, int width, int height, const glyphBitmap::Frame& frame, bool twoBit, + RenderMode mode, bool state) const; void drawLine(int x1, int y1, int x2, int y2, bool state = true) const; void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) const; void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const; diff --git a/lib/GfxRenderer/GlyphBitmap.h b/lib/GfxRenderer/GlyphBitmap.h new file mode 100644 index 00000000000..034a1b99c47 --- /dev/null +++ b/lib/GfxRenderer/GlyphBitmap.h @@ -0,0 +1,155 @@ +#pragma once + +#include +#include + +// Glyph rasterizer that resolves orientation, clipping and framebuffer +// addressing once per glyph instead of once per pixel. It has no dependency +// on GfxRenderer so it can be unit-tested on the host. +namespace glyphBitmap { + +// Framebuffer plane a glyph is painted into. +enum class Plane : uint8_t { BW, GrayLSB, GrayMSB }; + +// Position of glyph pixel (0, 0) plus the step for one move along the glyph's +// x and y axes, all in one coordinate space. The axes must describe an +// orthogonal unit rotation: each is +/-1 on exactly one component. +struct Frame { + int x; + int y; + int dxX; + int dxY; + int dyX; + int dyY; +}; + +// Where a glyph lands in physical framebuffer space. +struct Target { + uint8_t* buffer; // First byte of row originY (framebuffer or strip scratch) + int width; // Physical panel width in pixels + int stride; // Bytes per physical row + int originY; // First physical row held by buffer + int rows; // Number of physical rows held by buffer + Frame frame; // Physical placement of the glyph +}; + +// Half-open rectangle in glyph-local pixel coordinates. +struct Clip { + int left; + int top; + int right; + int bottom; +}; + +// Narrow [start, end) on one glyph axis so that base + i * step stays within +// [lower, upper). step is +1 or -1 because the transform is orthogonal. +inline void clipAxis(int base, int step, int lower, int upper, int& start, int& end) { + if (step > 0) { + start = std::max(start, lower - base); + end = std::min(end, upper - base); + } else { + start = std::max(start, base - upper + 1); + end = std::min(end, base - lower + 1); + } +} + +// Narrow a glyph-local clip so every kept pixel maps inside the half-open +// rectangle [left, right) x [top, bottom) of the space frame is expressed in. +// Which glyph axis runs along that space's x depends on the rotation: +// dxX != 0 means glyph x does. Runs twice per glyph, so it stays inline +// under -Os. +__attribute__((always_inline)) inline void clipToRect(const Frame& frame, int left, int top, int right, int bottom, + Clip& clip) { + if (frame.dxX != 0) { + clipAxis(frame.x, frame.dxX, left, right, clip.left, clip.right); + clipAxis(frame.y, frame.dyY, top, bottom, clip.top, clip.bottom); + } else { + clipAxis(frame.x, frame.dyX, left, right, clip.top, clip.bottom); + clipAxis(frame.y, frame.dxY, top, bottom, clip.left, clip.right); + } +} + +// Keep pixel writes inline when decoding a group of four pixels. +__attribute__((always_inline)) inline void paint(uint8_t* buffer, int destination, uint8_t ink, uint8_t levels, + bool clearBits) { + if ((levels & (1u << ink)) == 0) return; + const uint8_t mask = 0x80u >> (destination & 7); + if (clearBits) + buffer[destination >> 3] &= static_cast(~mask); + else + buffer[destination >> 3] |= mask; +} + +// Paint a packed glyph into target. +// +// bitmap: rows are contiguous, MSB first, 1 or 2 bits per pixel; widths need +// not be byte-aligned. 2bpp values are 0=white, 1=light gray, 2=dark gray, +// 3=black. 1bpp value 1 is ink. +// plane: which 2bpp values are painted. BW paints every non-white value and +// honours state (true clears the bit, i.e. black). The gray planes only ever +// set bits, because there 0 means leave alone and 1 means update: MSB paints +// both grays, LSB paints dark gray only. 1bpp glyphs paint ink with state on +// every plane. +// Clipping happens before pixel decoding, so fully hidden glyphs cost nothing. +inline void draw(const uint8_t* bitmap, int width, int height, bool twoBit, Plane plane, bool state, + const Target& target, Clip clip) { + // Bit n of levels set means source value n is painted. + uint8_t levels = 0x02; + bool clearBits = state; + if (twoBit) { + levels = plane == Plane::BW ? 0x0e : plane == Plane::GrayMSB ? 0x06 : 0x04; + if (plane != Plane::BW) clearBits = false; + } + + // Intersect with the glyph bounds, then with the panel width and the + // buffered row band. + clip.left = std::max(clip.left, 0); + clip.top = std::max(clip.top, 0); + clip.right = std::min(clip.right, width); + clip.bottom = std::min(clip.bottom, height); + clipToRect(target.frame, 0, target.originY, target.width, target.originY + target.rows, clip); + if (clip.left >= clip.right || clip.top >= clip.bottom) return; + + // Walk the framebuffer as a flat bit index. One glyph column advances + // stepX bits and one glyph row advances stepY bits; each is +/-1 for the + // axis that maps to physical x, or +/-strideBits for physical y. + const Frame& frame = target.frame; + const int strideBits = target.stride * 8; + const int stepX = frame.dxY * strideBits + frame.dxX; + const int stepY = frame.dyY * strideBits + frame.dyX; + int rowBit = (frame.y - target.originY) * strideBits + frame.x + clip.left * stepX + clip.top * stepY; + for (int y = clip.top; y < clip.bottom; ++y, rowBit += stepY) { + int source = y * width + clip.left; + int destination = rowBit; + int remaining = clip.right - clip.left; + if (twoBit) { + // Rows are bit-contiguous, not byte-padded. Align after any clipped prefix. + while (remaining && (source & 3)) { + paint(target.buffer, destination, (bitmap[source >> 2] >> (6 - (source & 3) * 2)) & 3, levels, clearBits); + ++source; + destination += stepX; + --remaining; + } + while (remaining >= 4) { + const uint8_t packed = bitmap[source >> 2]; + paint(target.buffer, destination, packed >> 6, levels, clearBits); + paint(target.buffer, destination + stepX, (packed >> 4) & 3, levels, clearBits); + paint(target.buffer, destination + 2 * stepX, (packed >> 2) & 3, levels, clearBits); + paint(target.buffer, destination + 3 * stepX, packed & 3, levels, clearBits); + source += 4; + destination += 4 * stepX; + remaining -= 4; + } + } + // One-bit glyphs and the clipped tail use scalar decoding. + while (remaining--) { + const uint8_t ink = twoBit ? ((bitmap[source >> 2] >> (6 - (source & 3) * 2)) & 3) + : ((bitmap[source >> 3] >> (7 - (source & 7))) & 1); + paint(target.buffer, destination, ink, levels, clearBits); + ++source; + destination += stepX; + } + } +} + +} // namespace glyphBitmap diff --git a/lib/I18n/translations/arabic.yaml b/lib/I18n/translations/arabic.yaml index 4a6a6ccc223..1aee80af151 100644 --- a/lib/I18n/translations/arabic.yaml +++ b/lib/I18n/translations/arabic.yaml @@ -30,6 +30,7 @@ STR_EMPTY_CHAPTER: "فصل فارغ" STR_INDEXING: "جاري الفهرسة" STR_INDEX_FAILED: "فشلت الفهرسة - كتاب غير صالح" STR_MEMORY_ERROR: "خطأ في الذاكرة" +STR_PREVIEW_UNAVAILABLE: "المعاينة غير متاحة" STR_PAGE_LOAD_ERROR: "خطأ في تحميل الصفحة" STR_EMPTY_FILE: "ملف فارغ" STR_OUT_OF_BOUNDS: "خارج النطاق" @@ -349,7 +350,7 @@ STR_OK_BUTTON: "موافق" STR_SLEEP_COVER_FILTER: "مرشح غلاف شاشة السكون" STR_SLEEP_COVER_FILTER_SHORT: "مرشح الغلاف" STR_FILTER_CONTRAST: "التباين" -STR_CUSTOMISE_STATUS_BAR: "تخصيص شريط الحالة" +STR_STATUS_BARS: "أشرطة الحالة" STR_CHAPTER_PAGE_COUNT: "عدد صفحات الفصل" STR_STABLE_PAGE_NUMBERS: "أرقام الصفحات الثابتة" STR_BOOK_PROGRESS_PERCENTAGE: "نسبة التقدم في الكتاب" @@ -719,6 +720,7 @@ STR_CUSTOM_BOOTSCREEN: "شاشة بدء مخصصة" STR_TAP_HIDE_STATUS_BAR: "نقر لإخفاء شريط الحالة" STR_LIBRARY_SORT_BY: "ترتيب حسب:" +STR_SORT: "ترتيب" STR_LIBRARY_DATE_ADDED: "تاريخ الإضافة" STR_LIBRARY_SERIES: "سلسلة" STR_LIBRARY_GENRE: "تصنيف" @@ -757,8 +759,18 @@ STR_LIBRARY_SETTINGS: "إعدادات المكتبة" STR_LIBRARY_LIST_VIEW: "عرض القائمة" STR_LIBRARY_EXPANDED: "موسع" STR_LIBRARY_SHOW_FILES: "إظهار الملفات" +STR_LIBRARY_FILES_COUNT: "%u ملفًا" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_LIBRARY_FINISHED_BOOKS: "الكتب المنتهية" + +STR_TOP_STATUS_BAR: "شريط الحالة العلوي" +STR_BOTTOM_STATUS_BAR: "شريط الحالة السفلي" +STR_STATUS_BAR_LEFT: "يسار" +STR_STATUS_BAR_RIGHT: "يمين" +STR_STATUS_BAR_EMPTY: "فارغ" +STR_STATUS_BAR_CLOCK: "الساعة" +STR_STATUS_BAR_BOTH: "كلاهما" +STR_CLOCK_OUTSIDE_READER: "الساعة خارج القارئ" diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index d1efd30c69e..ea509a58ca4 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Канец кнігі" STR_EMPTY_CHAPTER: "Пусты раздзел" STR_INDEXING: "Індэксацыя" STR_MEMORY_ERROR: "Памылка памяці" +STR_PREVIEW_UNAVAILABLE: "Папярэдні прагляд недаступны" STR_PAGE_LOAD_ERROR: "Памылка загрузкі старонкі" STR_EMPTY_FILE: "Пусты файл" STR_OUT_OF_BOUNDS: "Выхад за межы" @@ -379,7 +380,7 @@ STR_RESET_ALL_TIME_STATS_CONFIRM: "Ачысціць статыстыку за ў STR_SELECTED: "Выбрана" STR_SHOW: "Паказаць" STR_HIDE: "Схаваць" -STR_CUSTOMISE_STATUS_BAR: "Наладзіць радок стану" +STR_STATUS_BARS: "Радкі стану" STR_CHAPTER_PAGE_COUNT: "Колькасць старонак раздзела" STR_BOOK_PROGRESS_PERCENTAGE: "Працэнт прагрэсу кнігі" STR_PERCENTAGE_FORMAT: "Фармат працэнтаў" @@ -541,6 +542,7 @@ STR_TAP_HIDE_STATUS_BAR: "Націск хавае радок стану" STR_PREV_PAGE: "Папярэдняя старонка" STR_LIBRARY_SORT_BY: "Сартаваць:" +STR_SORT: "Сартаваць" STR_LIBRARY_DATE_ADDED: "Дата дадання" STR_LIBRARY_SERIES: "Серыя" STR_LIBRARY_GENRE: "Жанр" @@ -579,9 +581,19 @@ STR_LIBRARY_SETTINGS: "Налады бібліятэкі" STR_LIBRARY_LIST_VIEW: "Выгляд спіса" STR_LIBRARY_EXPANDED: "Разгорнуты" STR_LIBRARY_SHOW_FILES: "Паказваць файлы" +STR_LIBRARY_FILES_COUNT: "%u файлаў" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Кампактны" STR_LIBRARY_FINISHED_BOOKS: "Прачытаныя кнігі" + +STR_TOP_STATUS_BAR: "Верхні радок стану" +STR_BOTTOM_STATUS_BAR: "Ніжні радок стану" +STR_STATUS_BAR_LEFT: "Злева" +STR_STATUS_BAR_RIGHT: "Справа" +STR_STATUS_BAR_EMPTY: "Пуста" +STR_STATUS_BAR_CLOCK: "Гадзіннік" +STR_STATUS_BAR_BOTH: "Абодва" +STR_CLOCK_OUTSIDE_READER: "Гадзіннік па-за чытаннем" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 863bf65a312..07f10f697e9 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Final del llibre" STR_EMPTY_CHAPTER: "Capítol buit" STR_INDEXING: "S'està indexant" STR_MEMORY_ERROR: "Error de memòria" +STR_PREVIEW_UNAVAILABLE: "Previsualització no disponible" STR_PAGE_LOAD_ERROR: "Error en carregar la pàgina" STR_EMPTY_FILE: "Fitxer buit" STR_OUT_OF_BOUNDS: "Fora de límits" @@ -298,7 +299,7 @@ STR_DIR_DOWN: "Avall" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtre de pantalla de repòs" STR_FILTER_CONTRAST: "Contrast" -STR_CUSTOMISE_STATUS_BAR: "Personalitza la barra d'estat" +STR_STATUS_BARS: "Barres d’estat" STR_CHAPTER_PAGE_COUNT: "Comptador de pàgines del capítol" STR_BOOK_PROGRESS_PERCENTAGE: "Percentatge de progrés del llibre" STR_PERCENTAGE_FORMAT: "Format del percentatge" @@ -600,6 +601,7 @@ STR_CUSTOM_BOOTSCREEN: "Pantalla d’inici pròpia" STR_TAP_HIDE_STATUS_BAR: "Toca per amagar l’estat" STR_LIBRARY_SORT_BY: "Ordena per:" +STR_SORT: "Ordena" STR_LIBRARY_DATE_ADDED: "Data d’addició" STR_LIBRARY_SERIES: "Sèrie" STR_LIBRARY_GENRE: "Gènere" @@ -638,9 +640,19 @@ STR_LIBRARY_SETTINGS: "Configuració de la biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de llista" STR_LIBRARY_EXPANDED: "Ampliada" STR_LIBRARY_SHOW_FILES: "Mostra fitxers" +STR_LIBRARY_FILES_COUNT: "%u fitxers" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Compacta" STR_LIBRARY_FINISHED_BOOKS: "Llibres acabats" + +STR_TOP_STATUS_BAR: "Barra d’estat superior" +STR_BOTTOM_STATUS_BAR: "Barra d’estat inferior" +STR_STATUS_BAR_LEFT: "Esquerra" +STR_STATUS_BAR_RIGHT: "Dreta" +STR_STATUS_BAR_EMPTY: "Buit" +STR_STATUS_BAR_CLOCK: "Rellotge" +STR_STATUS_BAR_BOTH: "Ambdues" +STR_CLOCK_OUTSIDE_READER: "Rellotge fora del lector" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 3375af63959..d054b4d6fcc 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -30,6 +30,7 @@ STR_EMPTY_CHAPTER: "Prázdná kapitola" STR_INDEX_FAILED: "Indexování selhalo - neplatná kniha" STR_INDEXING: "Indexování" STR_MEMORY_ERROR: "Chyba paměti" +STR_PREVIEW_UNAVAILABLE: "Náhled není dostupný" STR_PAGE_LOAD_ERROR: "Chyba načítání stránky" STR_EMPTY_FILE: "Prázdný soubor" STR_OUT_OF_BOUNDS: "Mimo hranice" @@ -434,7 +435,7 @@ STR_CLOCK_FORMAT_24H: "24 hodin" STR_CLOCK_SYNCED: "Hodiny synchronizovány" STR_CLOCK_SYNC_NOW: "Synchronizovat datum/čas" STR_CLOCK_UTC_OFFSET: "Posun hodin UTC" -STR_CUSTOMISE_STATUS_BAR: "Přizpůsobit stavový řádek" +STR_STATUS_BARS: "Stavové řádky" STR_DISABLED: "Zakázáno" STR_DISPLAY_SLEEP_SCREEN: "Obrazovka spánku" STR_FONT_RANGE_ALL: "8-20pt" @@ -727,6 +728,7 @@ STR_CUSTOM_BOOTSCREEN: "Vlastní úvodní obrazovka" STR_TAP_HIDE_STATUS_BAR: "Klepnutím skrýt stavový řádek" STR_LIBRARY_SORT_BY: "Řadit podle:" +STR_SORT: "Řadit" STR_LIBRARY_DATE_ADDED: "Datum přidání" STR_LIBRARY_SERIES: "Série" STR_LIBRARY_GENRE: "Žánr" @@ -765,8 +767,18 @@ STR_LIBRARY_SETTINGS: "Nastavení knihovny" STR_LIBRARY_LIST_VIEW: "Zobrazení seznamu" STR_LIBRARY_EXPANDED: "Rozšířené" STR_LIBRARY_SHOW_FILES: "Zobrazit soubory" +STR_LIBRARY_FILES_COUNT: "%u souborů" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_LIBRARY_FINISHED_BOOKS: "Dočtené knihy" + +STR_TOP_STATUS_BAR: "Horní stavový řádek" +STR_BOTTOM_STATUS_BAR: "Dolní stavový řádek" +STR_STATUS_BAR_LEFT: "Vlevo" +STR_STATUS_BAR_RIGHT: "Vpravo" +STR_STATUS_BAR_EMPTY: "Prázdné" +STR_STATUS_BAR_CLOCK: "Hodiny" +STR_STATUS_BAR_BOTH: "Oba" +STR_CLOCK_OUTSIDE_READER: "Hodiny mimo čtečku" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index c7cb37d3d33..748b19e1ac8 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Bogen er færdig" STR_EMPTY_CHAPTER: "Tomt kapitel" STR_INDEXING: "Indekserer" STR_MEMORY_ERROR: "Hukommelsesfejl" +STR_PREVIEW_UNAVAILABLE: "Forhåndsvisning ikke tilgængelig" STR_PAGE_LOAD_ERROR: "Fejl ved sideindlæsning" STR_EMPTY_FILE: "Tom fil" STR_OUT_OF_BOUNDS: "Uden for grænsen" @@ -298,7 +299,7 @@ STR_DIR_DOWN: "Ned" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Hvile-skærm omslag-filter" STR_FILTER_CONTRAST: "Kontrast" -STR_CUSTOMISE_STATUS_BAR: "Tilpas statuslinje" +STR_STATUS_BARS: "Statuslinjer" STR_CHAPTER_PAGE_COUNT: "Kapitelsidetæller" STR_BOOK_PROGRESS_PERCENTAGE: "Bogfremskridtsprocent" STR_PERCENTAGE_FORMAT: "Procentformat" @@ -540,6 +541,7 @@ STR_TAP_HIDE_STATUS_BAR: "Tryk skjuler statuslinje" STR_PREV_PAGE: "Forrige side" STR_LIBRARY_SORT_BY: "Sortér efter:" +STR_SORT: "Sortér" STR_LIBRARY_DATE_ADDED: "Tilføjet dato" STR_LIBRARY_SERIES: "Serie" STR_LIBRARY_GENRE: "Genre" @@ -578,9 +580,19 @@ STR_LIBRARY_SETTINGS: "Biblioteksindstillinger" STR_LIBRARY_LIST_VIEW: "Listevisning" STR_LIBRARY_EXPANDED: "Udvidet" STR_LIBRARY_SHOW_FILES: "Vis filer" +STR_LIBRARY_FILES_COUNT: "%u filer" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Kompakt" STR_LIBRARY_FINISHED_BOOKS: "Færdiglæste bøger" + +STR_TOP_STATUS_BAR: "Øvre statuslinje" +STR_BOTTOM_STATUS_BAR: "Nedre statuslinje" +STR_STATUS_BAR_LEFT: "Venstre" +STR_STATUS_BAR_RIGHT: "Højre" +STR_STATUS_BAR_EMPTY: "Tom" +STR_STATUS_BAR_CLOCK: "Ur" +STR_STATUS_BAR_BOTH: "Begge" +STR_CLOCK_OUTSIDE_READER: "Ur uden for læseren" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 585d3413167..94acffbe250 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Einde van boek" STR_EMPTY_CHAPTER: "Leeg hoofdstuk" STR_INDEXING: "Indexeren" STR_MEMORY_ERROR: "Geheugenfout" +STR_PREVIEW_UNAVAILABLE: "Voorbeeld niet beschikbaar" STR_PAGE_LOAD_ERROR: "Fout bij laden pagina" STR_EMPTY_FILE: "Leeg bestand" STR_OUT_OF_BOUNDS: "Buiten bereik" @@ -297,7 +298,7 @@ STR_DIR_DOWN: "Omlaag" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Slaapscherm omslag-filter" STR_FILTER_CONTRAST: "Contrast" -STR_CUSTOMISE_STATUS_BAR: "Statusbalk aanpassen" +STR_STATUS_BARS: "Statusbalken" STR_CHAPTER_PAGE_COUNT: "Paginanummering hoofdstuk" STR_BOOK_PROGRESS_PERCENTAGE: "Percentage voortgang boek" STR_PERCENTAGE_FORMAT: "Percentageformaat" @@ -539,6 +540,7 @@ STR_TAP_HIDE_STATUS_BAR: "Tik om statusbalk te verbergen" STR_PREV_PAGE: "Vorige pagina" STR_LIBRARY_SORT_BY: "Sorteren op:" +STR_SORT: "Sorteren" STR_LIBRARY_DATE_ADDED: "Toegevoegd op" STR_LIBRARY_SERIES: "Reeks" STR_LIBRARY_GENRE: "Genre" @@ -577,9 +579,19 @@ STR_LIBRARY_SETTINGS: "Bibliotheekinstellingen" STR_LIBRARY_LIST_VIEW: "Lijstweergave" STR_LIBRARY_EXPANDED: "Uitgebreid" STR_LIBRARY_SHOW_FILES: "Bestanden tonen" +STR_LIBRARY_FILES_COUNT: "%u bestanden" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Compact" STR_LIBRARY_FINISHED_BOOKS: "Voltooide boeken" + +STR_TOP_STATUS_BAR: "Bovenste statusbalk" +STR_BOTTOM_STATUS_BAR: "Onderste statusbalk" +STR_STATUS_BAR_LEFT: "Links" +STR_STATUS_BAR_RIGHT: "Rechts" +STR_STATUS_BAR_EMPTY: "Leeg" +STR_STATUS_BAR_CLOCK: "Klok" +STR_STATUS_BAR_BOTH: "Beide" +STR_CLOCK_OUTSIDE_READER: "Klok buiten lezer" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 300258094b0..c3cc2633c36 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -29,6 +29,7 @@ STR_INDEX_FAILED: "Failed to index - invalid book" STR_EPUB_OPEN_FAILED_BODY: "CrossInk could not read this EPUB. The file may be invalid or damaged." STR_EPUB_OPEN_MEMORY_BODY: "CrossInk could not open this book because the device ran out of memory." STR_MEMORY_ERROR: "Memory error" +STR_PREVIEW_UNAVAILABLE: "Preview unavailable" STR_PAGE_LOAD_ERROR: "Page load error" STR_EMPTY_FILE: "Empty file" STR_OUT_OF_BOUNDS: "Out of bounds" @@ -462,7 +463,7 @@ STR_KEY_MODE_ABC: "abc" STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter" STR_SLEEP_COVER_FILTER_SHORT: "Cover Filter" STR_FILTER_CONTRAST: "Contrast" -STR_CUSTOMISE_STATUS_BAR: "Customize Status Bar" +STR_STATUS_BARS: "Status Bars" STR_CHAPTER_PAGE_COUNT: "Chapter Page Count" STR_STABLE_PAGE_NUMBERS: "Stable Page Numbers" STR_BOOK_PROGRESS_PERCENTAGE: "Book Progress Percentage" @@ -886,11 +887,12 @@ STR_LIBRARY_AUTHOR: "Author" STR_LIBRARY_AUTHOR_LAST_NAME: "Author (Last Name)" STR_LIBRARY_AUTHOR_FIRST_NAME: "Author (First Name)" STR_LIBRARY_SCANNING: "Reading your books..." -STR_LIBRARY_RESCAN: "Refresh library" +STR_LIBRARY_RESCAN: "Refresh Library" STR_LIBRARY_SCAN_FAILED: "Could not rebuild the index" STR_LIBRARY_UNSORTED: "Library (unsorted)" STR_LIBRARY_USE_METADATA: "Use Book Metadata" STR_LIBRARY_SORT_BY: "Sort By:" +STR_SORT: "Sort" STR_LIBRARY_DATE_ADDED: "Date Added" STR_LIBRARY_SERIES: "Series" STR_LIBRARY_GENRE: "Genre" @@ -902,7 +904,17 @@ STR_LIBRARY_FINISHED_BOOKS: "Finished Books" STR_LIBRARY_LIST_VIEW: "List View" STR_LIBRARY_EXPANDED: "Expanded" STR_LIBRARY_SHOW_FILES: "Show Files" +STR_LIBRARY_FILES_COUNT: "%u files" STR_LIBRARY_EPUBS: "EPUBs" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" + +STR_TOP_STATUS_BAR: "Top Status Bar" +STR_BOTTOM_STATUS_BAR: "Bottom Status Bar" +STR_STATUS_BAR_LEFT: "Left" +STR_STATUS_BAR_RIGHT: "Right" +STR_STATUS_BAR_EMPTY: "Empty" +STR_STATUS_BAR_CLOCK: "Clock" +STR_STATUS_BAR_BOTH: "Both" +STR_CLOCK_OUTSIDE_READER: "Clock Outside Reader" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 42862d4f25c..9a99466bcab 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Kirjan loppu" STR_EMPTY_CHAPTER: "Tyhjä luku" STR_INDEXING: "Indeksoidaan" STR_MEMORY_ERROR: "Muistivirhe" +STR_PREVIEW_UNAVAILABLE: "Esikatselu ei ole käytettävissä" STR_PAGE_LOAD_ERROR: "Sivun latausvirhe" STR_EMPTY_FILE: "Tyhjä tiedosto" STR_OUT_OF_BOUNDS: "Alueen ulkopuolella" @@ -422,7 +423,7 @@ STR_CLOCK_FORMAT_24H: "24 tuntia" STR_CLOCK_SYNCED: "Kello synkronoitu" STR_CLOCK_SYNC_NOW: "Synkronoi päivämäärä/aika nyt" STR_CLOCK_UTC_OFFSET: "Kellon UTC Offset" -STR_CUSTOMISE_STATUS_BAR: "Mukauta tilapalkkia" +STR_STATUS_BARS: "Tilapalkit" STR_DISABLED: "Ei käytössä" STR_DISPLAY_SLEEP_SCREEN: "Lepotilanäyttö" STR_FONT_RANGE_ALL: "8-20pt" @@ -529,6 +530,7 @@ STR_TAP_HIDE_STATUS_BAR: "Piilota tilapalkki napauttamalla" STR_PREV_PAGE: "Edellinen sivu" STR_LIBRARY_SORT_BY: "Järjestä:" +STR_SORT: "Järjestä" STR_LIBRARY_DATE_ADDED: "Lisäyspäivä" STR_LIBRARY_SERIES: "Sarja" STR_LIBRARY_GENRE: "Lajityyppi" @@ -567,9 +569,19 @@ STR_LIBRARY_SETTINGS: "Kirjaston asetukset" STR_LIBRARY_LIST_VIEW: "Luettelonäkymä" STR_LIBRARY_EXPANDED: "Laajennettu" STR_LIBRARY_SHOW_FILES: "Näytä tiedostot" +STR_LIBRARY_FILES_COUNT: "%u tiedostoa" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Tiivis" STR_LIBRARY_FINISHED_BOOKS: "Luetut kirjat" + +STR_TOP_STATUS_BAR: "Ylempi tilapalkki" +STR_BOTTOM_STATUS_BAR: "Alempi tilapalkki" +STR_STATUS_BAR_LEFT: "Vasen" +STR_STATUS_BAR_RIGHT: "Oikea" +STR_STATUS_BAR_EMPTY: "Tyhjä" +STR_STATUS_BAR_CLOCK: "Kello" +STR_STATUS_BAR_BOTH: "Molemmat" +STR_CLOCK_OUTSIDE_READER: "Kello lukijan ulkopuolella" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index eb72dccbdc0..4ba6334b7f2 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Fin du livre" STR_EMPTY_CHAPTER: "Chapitre vide" STR_INDEXING: "Indexation en cours" STR_MEMORY_ERROR: "Erreur de mémoire" +STR_PREVIEW_UNAVAILABLE: "Aperçu indisponible" STR_PAGE_LOAD_ERROR: "Erreur de chargement" STR_EMPTY_FILE: "Fichier vide" STR_OUT_OF_BOUNDS: "Dépassement de mémoire" @@ -298,7 +299,7 @@ STR_DIR_DOWN: "Bas" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtre écran de veille" STR_FILTER_CONTRAST: "Contraste" -STR_CUSTOMISE_STATUS_BAR: "Personnaliser la barre d'état" +STR_STATUS_BARS: "Barres d’état" STR_CHAPTER_PAGE_COUNT: "Nombre de pages du chapitre" STR_BOOK_PROGRESS_PERCENTAGE: "Pourcentage de progression" STR_PERCENTAGE_FORMAT: "Format du pourcentage" @@ -568,6 +569,7 @@ STR_LIBRARY_SCAN_FAILED: "Échec de la reconstruction" STR_LIBRARY_UNSORTED: "Bibliothèque (non triée)" STR_LIBRARY_USE_METADATA: "Utiliser les métadonnées" STR_LIBRARY_SORT_BY: "Trier par :" +STR_SORT: "Trier" STR_LIBRARY_DATE_ADDED: "Date d’ajout" STR_LIBRARY_SERIES: "Série" STR_LIBRARY_GENRE: "Genre" @@ -578,9 +580,19 @@ STR_LIBRARY_SETTINGS: "Paramètres de la bibliothèque" STR_LIBRARY_LIST_VIEW: "Vue en liste" STR_LIBRARY_EXPANDED: "Développée" STR_LIBRARY_SHOW_FILES: "Afficher les fichiers" +STR_LIBRARY_FILES_COUNT: "%u fichiers" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Compacte" STR_LIBRARY_FINISHED_BOOKS: "Livres terminés" + +STR_TOP_STATUS_BAR: "Barre d’état supérieure" +STR_BOTTOM_STATUS_BAR: "Barre d’état inférieure" +STR_STATUS_BAR_LEFT: "Gauche" +STR_STATUS_BAR_RIGHT: "Droite" +STR_STATUS_BAR_EMPTY: "Vide" +STR_STATUS_BAR_CLOCK: "Horloge" +STR_STATUS_BAR_BOTH: "Les deux" +STR_CLOCK_OUTSIDE_READER: "Horloge hors lecture" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index b2328da60c8..eb3df24f2e5 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -29,6 +29,7 @@ STR_END_OF_BOOK: "Buchende" STR_EMPTY_CHAPTER: "Leeres Kapitel" STR_INDEXING: "Indexiert" STR_MEMORY_ERROR: "Speicherfehler" +STR_PREVIEW_UNAVAILABLE: "Vorschau nicht verfügbar" STR_PAGE_LOAD_ERROR: "Seitenladefehler" STR_EMPTY_FILE: "Leere Datei" STR_OUT_OF_BOUNDS: "Zu groß" @@ -358,7 +359,7 @@ STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Standby-Coverfilter" STR_SLEEP_COVER_FILTER_SHORT: "Coverfilter" STR_FILTER_CONTRAST: "Kontrast" -STR_CUSTOMISE_STATUS_BAR: "Statusleiste anpassen" +STR_STATUS_BARS: "Statusleisten" STR_CHAPTER_PAGE_COUNT: "Kapitel-Seitenanzahl" STR_BOOK_PROGRESS_PERCENTAGE: "Buchfortschritt in %" STR_PERCENTAGE_FORMAT: "Prozentformat" @@ -745,6 +746,7 @@ STR_LIBRARY_SCAN_FAILED: "Kann Index nicht erstellen" STR_LIBRARY_UNSORTED: "Bibliothek (unsortiert)" STR_LIBRARY_USE_METADATA: "Benutze Metadaten" STR_LIBRARY_SORT_BY: "Sortieren nach:" +STR_SORT: "Sortieren" STR_LIBRARY_DATE_ADDED: "Hinzugefügt am" STR_LIBRARY_SERIES: "Reihe" STR_LIBRARY_GENRE: "Genre" @@ -755,8 +757,18 @@ STR_LIBRARY_SETTINGS: "Bibliothekseinstellungen" STR_LIBRARY_LIST_VIEW: "Listenansicht" STR_LIBRARY_EXPANDED: "Erweitert" STR_LIBRARY_SHOW_FILES: "Dateien anzeigen" +STR_LIBRARY_FILES_COUNT: "%u Dateien" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_LIBRARY_FINISHED_BOOKS: "Fertiggelesene Bücher" + +STR_TOP_STATUS_BAR: "Obere Statusleiste" +STR_BOTTOM_STATUS_BAR: "Untere Statusleiste" +STR_STATUS_BAR_LEFT: "Links" +STR_STATUS_BAR_RIGHT: "Rechts" +STR_STATUS_BAR_EMPTY: "Leer" +STR_STATUS_BAR_CLOCK: "Uhr" +STR_STATUS_BAR_BOTH: "Beide" +STR_CLOCK_OUTSIDE_READER: "Uhr außerhalb des Lesers" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index d6a3e80e1b3..e82bd4c4abc 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "סוף הספר" STR_EMPTY_CHAPTER: "פרק ריק" STR_INDEXING: "מאנדקס..." STR_MEMORY_ERROR: "שגיאת זיכרון" +STR_PREVIEW_UNAVAILABLE: "תצוגה מקדימה אינה זמינה" STR_PAGE_LOAD_ERROR: "שגיאה בטעינת הדף" STR_EMPTY_FILE: "קובץ ריק" STR_OUT_OF_BOUNDS: "מחוץ לטווח" @@ -286,7 +287,7 @@ STR_DIR_DOWN: "למטה" STR_OK_BUTTON: "אישור" STR_SLEEP_COVER_FILTER: "פילטר תמונה במצב שינה" STR_FILTER_CONTRAST: "ניגודיות (קונטרסט)" -STR_CUSTOMISE_STATUS_BAR: "התאם אישית שורת מצב" +STR_STATUS_BARS: "שורות מצב" STR_CHAPTER_PAGE_COUNT: "מספר דפים בפרק" STR_BOOK_PROGRESS_PERCENTAGE: "אחוז התקדמות בספר" STR_PERCENTAGE_FORMAT: "תבנית אחוזים" @@ -536,6 +537,7 @@ STR_CUSTOM_BOOTSCREEN: "מסך פתיחה מותאם" STR_TAP_HIDE_STATUS_BAR: "הקשה להסתרת שורת מצב" STR_LIBRARY_SORT_BY: "מיון לפי:" +STR_SORT: "מיון" STR_LIBRARY_DATE_ADDED: "תאריך הוספה" STR_LIBRARY_SERIES: "סדרה" STR_LIBRARY_GENRE: "סוגה" @@ -575,9 +577,19 @@ STR_LIBRARY_SETTINGS: "הגדרות ספרייה" STR_LIBRARY_LIST_VIEW: "תצוגת רשימה" STR_LIBRARY_EXPANDED: "מורחב" STR_LIBRARY_SHOW_FILES: "הצגת קבצים" +STR_LIBRARY_FILES_COUNT: "%u קבצים" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "קומפקטי" STR_LIBRARY_FINISHED_BOOKS: "ספרים שהושלמו" + +STR_TOP_STATUS_BAR: "שורת מצב עליונה" +STR_BOTTOM_STATUS_BAR: "שורת מצב תחתונה" +STR_STATUS_BAR_LEFT: "שמאל" +STR_STATUS_BAR_RIGHT: "ימין" +STR_STATUS_BAR_EMPTY: "ריק" +STR_STATUS_BAR_CLOCK: "שעון" +STR_STATUS_BAR_BOTH: "שניהם" +STR_CLOCK_OUTSIDE_READER: "שעון מחוץ לקורא" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 9166a58b835..094af4fbaa5 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "A könyv vége" STR_EMPTY_CHAPTER: "Üres fejezet" STR_INDEXING: "Indexelés" STR_MEMORY_ERROR: "Memóriahiba" +STR_PREVIEW_UNAVAILABLE: "Az előnézet nem érhető el" STR_PAGE_LOAD_ERROR: "Lapbetöltési hiba" STR_EMPTY_FILE: "Üres fájl" STR_OUT_OF_BOUNDS: "Határon kívül" @@ -297,7 +298,7 @@ STR_DIR_DOWN: "Le" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Alvásképernyő borítószűrő" STR_FILTER_CONTRAST: "Kontraszt" -STR_CUSTOMISE_STATUS_BAR: "Állapotsáv testreszabása" +STR_STATUS_BARS: "Állapotsorok" STR_CHAPTER_PAGE_COUNT: "Fejezet oldalszám" STR_BOOK_PROGRESS_PERCENTAGE: "Könyv haladás százaléka" STR_PERCENTAGE_FORMAT: "Százalékformátum" @@ -540,6 +541,7 @@ STR_TAP_HIDE_STATUS_BAR: "Állapotsáv elrejtése koppintással" STR_PREV_PAGE: "Előző oldal" STR_LIBRARY_SORT_BY: "Rendezés:" +STR_SORT: "Rendezés" STR_LIBRARY_DATE_ADDED: "Hozzáadás dátuma" STR_LIBRARY_SERIES: "Sorozat" STR_LIBRARY_GENRE: "Műfaj" @@ -578,9 +580,19 @@ STR_LIBRARY_SETTINGS: "Könyvtárbeállítások" STR_LIBRARY_LIST_VIEW: "Lista nézet" STR_LIBRARY_EXPANDED: "Bővített" STR_LIBRARY_SHOW_FILES: "Fájlok megjelenítése" +STR_LIBRARY_FILES_COUNT: "%u fájl" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Kompakt" STR_LIBRARY_FINISHED_BOOKS: "Befejezett könyvek" + +STR_TOP_STATUS_BAR: "Felső állapotsor" +STR_BOTTOM_STATUS_BAR: "Alsó állapotsor" +STR_STATUS_BAR_LEFT: "Bal" +STR_STATUS_BAR_RIGHT: "Jobb" +STR_STATUS_BAR_EMPTY: "Üres" +STR_STATUS_BAR_CLOCK: "Óra" +STR_STATUS_BAR_BOTH: "Mindkettő" +STR_CLOCK_OUTSIDE_READER: "Óra az olvasón kívül" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 7c778598257..e473fc17e55 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Fine del libro" STR_EMPTY_CHAPTER: "Capitolo vuoto" STR_INDEXING: "Indicizzazione..." STR_MEMORY_ERROR: "Errore di memoria" +STR_PREVIEW_UNAVAILABLE: "Anteprima non disponibile" STR_PAGE_LOAD_ERROR: "Errore caricamento pagina" STR_EMPTY_FILE: "File vuoto" STR_OUT_OF_BOUNDS: "Fuori dai limiti" @@ -301,7 +302,7 @@ STR_DIR_DOWN: "Giù" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtro copertina" STR_FILTER_CONTRAST: "Contrasto" -STR_CUSTOMISE_STATUS_BAR: "Personalizza barra di stato" +STR_STATUS_BARS: "Barre di stato" STR_CHAPTER_PAGE_COUNT: "Conteggio pagine capitolo" STR_BOOK_PROGRESS_PERCENTAGE: "% avanzamento lettura" STR_PERCENTAGE_FORMAT: "Formato percentuale" @@ -610,6 +611,7 @@ STR_CUSTOM_BOOTSCREEN: "Schermata avvio personale" STR_TAP_HIDE_STATUS_BAR: "Tocca per nascondere lo stato" STR_LIBRARY_SORT_BY: "Ordina per:" +STR_SORT: "Ordina" STR_LIBRARY_DATE_ADDED: "Data di aggiunta" STR_LIBRARY_SERIES: "Serie" STR_LIBRARY_GENRE: "Genere" @@ -648,9 +650,19 @@ STR_LIBRARY_SETTINGS: "Impostazioni libreria" STR_LIBRARY_LIST_VIEW: "Vista elenco" STR_LIBRARY_EXPANDED: "Espansa" STR_LIBRARY_SHOW_FILES: "Mostra file" +STR_LIBRARY_FILES_COUNT: "%u file" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Compatta" STR_LIBRARY_FINISHED_BOOKS: "Libri finiti" + +STR_TOP_STATUS_BAR: "Barra di stato superiore" +STR_BOTTOM_STATUS_BAR: "Barra di stato inferiore" +STR_STATUS_BAR_LEFT: "Sinistra" +STR_STATUS_BAR_RIGHT: "Destra" +STR_STATUS_BAR_EMPTY: "Vuoto" +STR_STATUS_BAR_CLOCK: "Orologio" +STR_STATUS_BAR_BOTH: "Entrambe" +STR_CLOCK_OUTSIDE_READER: "Orologio fuori dal lettore" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 3b55fdd39aa..2108af27e81 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -32,6 +32,7 @@ STR_END_OF_BOOK: "Кітап аяқталды" STR_EMPTY_CHAPTER: "Бос тарау" STR_INDEXING: "Индекстелуде" STR_MEMORY_ERROR: "Жад қатесі" +STR_PREVIEW_UNAVAILABLE: "Алдын ала қарау қолжетімсіз" STR_PAGE_LOAD_ERROR: "Бетті жүктеу қатесі" STR_EMPTY_FILE: "Бос файл" STR_OUT_OF_BOUNDS: "Шектен тыс" @@ -366,7 +367,7 @@ STR_IMAGES_SUPPRESS: "Жасыру" STR_SELECTED: "Таңдалды" STR_SHOW: "Көрсету" STR_HIDE: "Жасыру" -STR_CUSTOMISE_STATUS_BAR: "Күй жолағын баптау" +STR_STATUS_BARS: "Күй жолақтары" STR_CHAPTER_PAGE_COUNT: "Тараудың бет саны" STR_BOOK_PROGRESS_PERCENTAGE: "Кітап үлгерімі пайызы" STR_PERCENTAGE_FORMAT: "Пайыз пішімі" @@ -540,6 +541,7 @@ STR_TAP_HIDE_STATUS_BAR: "Түртіп күй жолағын жасыру" STR_PREV_PAGE: "Алдыңғы бет" STR_LIBRARY_SORT_BY: "Сұрыптау:" +STR_SORT: "Сұрыптау" STR_LIBRARY_DATE_ADDED: "Қосылған күні" STR_LIBRARY_SERIES: "Серия" STR_LIBRARY_GENRE: "Жанр" @@ -578,9 +580,19 @@ STR_LIBRARY_SETTINGS: "Кітапхана параметрлері" STR_LIBRARY_LIST_VIEW: "Тізім көрінісі" STR_LIBRARY_EXPANDED: "Кеңейтілген" STR_LIBRARY_SHOW_FILES: "Файлдарды көрсету" +STR_LIBRARY_FILES_COUNT: "%u файл" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Ықшам" STR_LIBRARY_FINISHED_BOOKS: "Аяқталған кітаптар" + +STR_TOP_STATUS_BAR: "Жоғарғы күй жолағы" +STR_BOTTOM_STATUS_BAR: "Төменгі күй жолағы" +STR_STATUS_BAR_LEFT: "Сол" +STR_STATUS_BAR_RIGHT: "Оң" +STR_STATUS_BAR_EMPTY: "Бос" +STR_STATUS_BAR_CLOCK: "Сағат" +STR_STATUS_BAR_BOTH: "Екеуі" +STR_CLOCK_OUTSIDE_READER: "Оқудан тыс сағат" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 019fc7c27df..4262752b9fb 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Knygos pabaiga" STR_EMPTY_CHAPTER: "Tuščias skyrius" STR_INDEXING: "Indeksuojama" STR_MEMORY_ERROR: "Atminties klaida" +STR_PREVIEW_UNAVAILABLE: "Peržiūra negalima" STR_PAGE_LOAD_ERROR: "Puslapio klaida" STR_EMPTY_FILE: "Tuščias failas" STR_OUT_OF_BOUNDS: "Už ribų" @@ -295,7 +296,7 @@ STR_DIR_DOWN: "Apačia" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Viršelio filtras" STR_FILTER_CONTRAST: "Kontrastas" -STR_CUSTOMISE_STATUS_BAR: "Būsenos juosta" +STR_STATUS_BARS: "Būsenos juostos" STR_CHAPTER_PAGE_COUNT: "Skyriaus psl." STR_BOOK_PROGRESS_PERCENTAGE: "Progresas %" STR_PERCENTAGE_FORMAT: "Procentų formatas" @@ -540,6 +541,7 @@ STR_TAP_HIDE_STATUS_BAR: "Bakstelėjus slėpti būsenos juostą" STR_PREV_PAGE: "Ankstesnis puslapis" STR_LIBRARY_SORT_BY: "Rikiuoti pagal:" +STR_SORT: "Rikiuoti" STR_LIBRARY_DATE_ADDED: "Pridėjimo data" STR_LIBRARY_SERIES: "Serija" STR_LIBRARY_GENRE: "Žanras" @@ -578,9 +580,19 @@ STR_LIBRARY_SETTINGS: "Bibliotekos nustatymai" STR_LIBRARY_LIST_VIEW: "Sąrašo rodinys" STR_LIBRARY_EXPANDED: "Išplėstinis" STR_LIBRARY_SHOW_FILES: "Rodyti failus" +STR_LIBRARY_FILES_COUNT: "%u failų" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Kompaktiškas" STR_LIBRARY_FINISHED_BOOKS: "Baigtos knygos" + +STR_TOP_STATUS_BAR: "Viršutinė būsenos juosta" +STR_BOTTOM_STATUS_BAR: "Apatinė būsenos juosta" +STR_STATUS_BAR_LEFT: "Kairė" +STR_STATUS_BAR_RIGHT: "Dešinė" +STR_STATUS_BAR_EMPTY: "Tuščia" +STR_STATUS_BAR_CLOCK: "Laikrodis" +STR_STATUS_BAR_BOTH: "Abi" +STR_CLOCK_OUTSIDE_READER: "Laikrodis ne skaityklėje" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 104940fba47..413170e5dbc 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Koniec książki" STR_EMPTY_CHAPTER: "Pusty rozdział" STR_INDEXING: "Indeksowanie" STR_MEMORY_ERROR: "Błąd pamięci" +STR_PREVIEW_UNAVAILABLE: "Podgląd niedostępny" STR_PAGE_LOAD_ERROR: "Błąd ładowania strony" STR_EMPTY_FILE: "Pusty plik" STR_OUT_OF_BOUNDS: "Poza granicami" @@ -298,7 +299,7 @@ STR_DIR_DOWN: "Dół" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtr okładek wygaszacza" STR_FILTER_CONTRAST: "Kontrast" -STR_CUSTOMISE_STATUS_BAR: "Dostosowanie paska statusu" +STR_STATUS_BARS: "Paski stanu" STR_CHAPTER_PAGE_COUNT: "Strona rozdziału" STR_BOOK_PROGRESS_PERCENTAGE: "Postęp książki" STR_PERCENTAGE_FORMAT: "Format procentów" @@ -560,6 +561,7 @@ STR_TAP_HIDE_STATUS_BAR: "Dotknij, by ukryć pasek stanu" STR_PREV_PAGE: "Poprzednia strona" STR_LIBRARY_SORT_BY: "Sortuj według:" +STR_SORT: "Sortuj" STR_LIBRARY_DATE_ADDED: "Data dodania" STR_LIBRARY_SERIES: "Seria" STR_LIBRARY_GENRE: "Gatunek" @@ -598,9 +600,19 @@ STR_LIBRARY_SETTINGS: "Ustawienia biblioteki" STR_LIBRARY_LIST_VIEW: "Widok listy" STR_LIBRARY_EXPANDED: "Rozszerzony" STR_LIBRARY_SHOW_FILES: "Pokaż pliki" +STR_LIBRARY_FILES_COUNT: "%u plików" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Kompaktowy" STR_LIBRARY_FINISHED_BOOKS: "Ukończone książki" + +STR_TOP_STATUS_BAR: "Górny pasek stanu" +STR_BOTTOM_STATUS_BAR: "Dolny pasek stanu" +STR_STATUS_BAR_LEFT: "Lewo" +STR_STATUS_BAR_RIGHT: "Prawo" +STR_STATUS_BAR_EMPTY: "Puste" +STR_STATUS_BAR_CLOCK: "Zegar" +STR_STATUS_BAR_BOTH: "Oba" +STR_CLOCK_OUTSIDE_READER: "Zegar poza czytnikiem" diff --git a/lib/I18n/translations/portuguese-BR.yaml b/lib/I18n/translations/portuguese-BR.yaml index cfa56332d88..4bcd72b5f91 100644 --- a/lib/I18n/translations/portuguese-BR.yaml +++ b/lib/I18n/translations/portuguese-BR.yaml @@ -29,6 +29,7 @@ STR_END_OF_BOOK: "Fim do livro" STR_EMPTY_CHAPTER: "Capítulo vazio" STR_INDEXING: "Indexando" STR_MEMORY_ERROR: "Erro de memória" +STR_PREVIEW_UNAVAILABLE: "Prévia indisponível" STR_PAGE_LOAD_ERROR: "Erro ao carregar página" STR_EMPTY_FILE: "Arquivo vazio" STR_OUT_OF_BOUNDS: "Fora dos limites" @@ -343,7 +344,7 @@ STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso" STR_SLEEP_COVER_FILTER_SHORT: "Filtro da capa" STR_FILTER_CONTRAST: "Contraste" -STR_CUSTOMISE_STATUS_BAR: "Personalizar barra de status" +STR_STATUS_BARS: "Barras de status" STR_CHAPTER_PAGE_COUNT: "Contagem de páginas do capítulo" STR_STABLE_PAGE_NUMBERS: "Números de página estáveis" STR_BOOK_PROGRESS_PERCENTAGE: "Porcentagem de progresso do livro" @@ -715,6 +716,7 @@ STR_CUSTOM_BOOTSCREEN: "Tela inicial personalizada" STR_TAP_HIDE_STATUS_BAR: "Toque para ocultar barra de status" STR_LIBRARY_SORT_BY: "Ordenar por:" +STR_SORT: "Ordenar" STR_LIBRARY_DATE_ADDED: "Data de adição" STR_LIBRARY_SERIES: "Série" STR_LIBRARY_GENRE: "Gênero" @@ -753,8 +755,18 @@ STR_LIBRARY_SETTINGS: "Configurações da biblioteca" STR_LIBRARY_LIST_VIEW: "Visualização em lista" STR_LIBRARY_EXPANDED: "Expandida" STR_LIBRARY_SHOW_FILES: "Mostrar arquivos" +STR_LIBRARY_FILES_COUNT: "%u arquivos" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_LIBRARY_FINISHED_BOOKS: "Livros concluídos" + +STR_TOP_STATUS_BAR: "Barra de status superior" +STR_BOTTOM_STATUS_BAR: "Barra de status inferior" +STR_STATUS_BAR_LEFT: "Esquerda" +STR_STATUS_BAR_RIGHT: "Direita" +STR_STATUS_BAR_EMPTY: "Vazio" +STR_STATUS_BAR_CLOCK: "Relógio" +STR_STATUS_BAR_BOTH: "Ambas" +STR_CLOCK_OUTSIDE_READER: "Relógio fora do leitor" diff --git a/lib/I18n/translations/portuguese-PT.yaml b/lib/I18n/translations/portuguese-PT.yaml index 5cd7b14da71..7e34bbf8c0c 100644 --- a/lib/I18n/translations/portuguese-PT.yaml +++ b/lib/I18n/translations/portuguese-PT.yaml @@ -29,6 +29,7 @@ STR_EMPTY_CHAPTER: "Capítulo vazio" STR_INDEXING: "A indexar" STR_INDEX_FAILED: "Falha ao indexar - livro inválido" STR_MEMORY_ERROR: "Erro de memória" +STR_PREVIEW_UNAVAILABLE: "Pré-visualização indisponível" STR_PAGE_LOAD_ERROR: "Erro a carregar a página" STR_EMPTY_FILE: "Ficheiro vazio" STR_OUT_OF_BOUNDS: "Fora dos limites" @@ -422,7 +423,7 @@ STR_KEY_MODE_ABC: "abc" STR_SLEEP_COVER_FILTER: "Filtro da capa do ecrã de repouso" STR_SLEEP_COVER_FILTER_SHORT: "Filtro da capa" STR_FILTER_CONTRAST: "Contraste" -STR_CUSTOMISE_STATUS_BAR: "Personalizar barra de estado" +STR_STATUS_BARS: "Barras de estado" STR_CHAPTER_PAGE_COUNT: "Contagem de páginas do capítulo" STR_STABLE_PAGE_NUMBERS: "Números de página estáveis" STR_BOOK_PROGRESS_PERCENTAGE: "Percentagem de progresso do livro" @@ -811,6 +812,7 @@ STR_CUSTOM_BOOTSCREEN: "Ecrã inicial personalizado" STR_TAP_HIDE_STATUS_BAR: "Toque para ocultar barra de estado" STR_LIBRARY_SORT_BY: "Ordenar por:" +STR_SORT: "Ordenar" STR_LIBRARY_DATE_ADDED: "Data de adição" STR_LIBRARY_SERIES: "Série" STR_LIBRARY_GENRE: "Género" @@ -849,8 +851,18 @@ STR_LIBRARY_SETTINGS: "Definições da biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de lista" STR_LIBRARY_EXPANDED: "Expandida" STR_LIBRARY_SHOW_FILES: "Mostrar ficheiros" +STR_LIBRARY_FILES_COUNT: "%u ficheiros" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_LIBRARY_FINISHED_BOOKS: "Livros concluídos" + +STR_TOP_STATUS_BAR: "Barra de estado superior" +STR_BOTTOM_STATUS_BAR: "Barra de estado inferior" +STR_STATUS_BAR_LEFT: "Esquerda" +STR_STATUS_BAR_RIGHT: "Direita" +STR_STATUS_BAR_EMPTY: "Vazio" +STR_STATUS_BAR_CLOCK: "Relógio" +STR_STATUS_BAR_BOTH: "Ambas" +STR_CLOCK_OUTSIDE_READER: "Relógio fora do leitor" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 7919d3c70e3..2a5fa150119 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Sfârşitul cărţii" STR_EMPTY_CHAPTER: "Capitol gol" STR_INDEXING: "Indexează..." STR_MEMORY_ERROR: "Eroare de memorie" +STR_PREVIEW_UNAVAILABLE: "Previzualizare indisponibilă" STR_PAGE_LOAD_ERROR: "Eroare la încărcarea paginii" STR_EMPTY_FILE: "Fişier gol" STR_OUT_OF_BOUNDS: "Eroare: În afara limitelor" @@ -297,7 +298,7 @@ STR_DIR_DOWN: "Jos" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtru ecran de repaus" STR_FILTER_CONTRAST: "Contrast" -STR_CUSTOMISE_STATUS_BAR: "Customizaţi bara de stare" +STR_STATUS_BARS: "Bare de stare" STR_CHAPTER_PAGE_COUNT: "Număr de pagini în capitol" STR_BOOK_PROGRESS_PERCENTAGE: "Progres carte procentual" STR_PERCENTAGE_FORMAT: "Format procentual" @@ -539,6 +540,7 @@ STR_TAP_HIDE_STATUS_BAR: "Atinge pentru a ascunde starea" STR_PREV_PAGE: "Pagina anterioară" STR_LIBRARY_SORT_BY: "Sortare după:" +STR_SORT: "Sortare" STR_LIBRARY_DATE_ADDED: "Data adăugării" STR_LIBRARY_SERIES: "Serie" STR_LIBRARY_GENRE: "Gen" @@ -577,9 +579,19 @@ STR_LIBRARY_SETTINGS: "Setări bibliotecă" STR_LIBRARY_LIST_VIEW: "Vizualizare listă" STR_LIBRARY_EXPANDED: "Extinsă" STR_LIBRARY_SHOW_FILES: "Afișează fișiere" +STR_LIBRARY_FILES_COUNT: "%u fișiere" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Compactă" STR_LIBRARY_FINISHED_BOOKS: "Cărți terminate" + +STR_TOP_STATUS_BAR: "Bară de stare sus" +STR_BOTTOM_STATUS_BAR: "Bară de stare jos" +STR_STATUS_BAR_LEFT: "Stânga" +STR_STATUS_BAR_RIGHT: "Dreapta" +STR_STATUS_BAR_EMPTY: "Gol" +STR_STATUS_BAR_CLOCK: "Ceas" +STR_STATUS_BAR_BOTH: "Ambele" +STR_CLOCK_OUTSIDE_READER: "Ceas în afara cititorului" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 15cbe7c2a0e..7e1d91515e1 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Конец книги" STR_EMPTY_CHAPTER: "Пустая глава" STR_INDEXING: "Индексация" STR_MEMORY_ERROR: "Ошибка памяти" +STR_PREVIEW_UNAVAILABLE: "Предпросмотр недоступен" STR_PAGE_LOAD_ERROR: "Ошибка загрузки страницы" STR_EMPTY_FILE: "Пустой файл" STR_OUT_OF_BOUNDS: "Выход за пределы" @@ -305,7 +306,7 @@ STR_DIR_DOWN: "Вниз" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Фильтр экрана сна" STR_FILTER_CONTRAST: "Контраст" -STR_CUSTOMISE_STATUS_BAR: "Настройка строки состояния" +STR_STATUS_BARS: "Строки состояния" STR_CHAPTER_PAGE_COUNT: "Количество страниц главы" STR_BOOK_PROGRESS_PERCENTAGE: "% прочтения книги" STR_PERCENTAGE_FORMAT: "Формат процентов" @@ -607,6 +608,7 @@ STR_CUSTOM_BOOTSCREEN: "Свой экран запуска" STR_TAP_HIDE_STATUS_BAR: "Касание скрывает строку состояния" STR_LIBRARY_SORT_BY: "Сортировать:" +STR_SORT: "Сортировать" STR_LIBRARY_DATE_ADDED: "Дата добавления" STR_LIBRARY_SERIES: "Серия" STR_LIBRARY_GENRE: "Жанр" @@ -645,9 +647,19 @@ STR_LIBRARY_SETTINGS: "Настройки библиотеки" STR_LIBRARY_LIST_VIEW: "Вид списка" STR_LIBRARY_EXPANDED: "Расширенный" STR_LIBRARY_SHOW_FILES: "Показывать файлы" +STR_LIBRARY_FILES_COUNT: "%u файлов" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Компактный" STR_LIBRARY_FINISHED_BOOKS: "Прочитанные книги" + +STR_TOP_STATUS_BAR: "Верхняя строка состояния" +STR_BOTTOM_STATUS_BAR: "Нижняя строка состояния" +STR_STATUS_BAR_LEFT: "Слева" +STR_STATUS_BAR_RIGHT: "Справа" +STR_STATUS_BAR_EMPTY: "Пусто" +STR_STATUS_BAR_CLOCK: "Часы" +STR_STATUS_BAR_BOTH: "Обе" +STR_CLOCK_OUTSIDE_READER: "Часы вне режима чтения" diff --git a/lib/I18n/translations/slovak.yaml b/lib/I18n/translations/slovak.yaml index 1df6004e737..73e8a9152ce 100644 --- a/lib/I18n/translations/slovak.yaml +++ b/lib/I18n/translations/slovak.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Koniec knihy" STR_EMPTY_CHAPTER: "Prázdna kapitola" STR_INDEXING: "Indexovanie" STR_MEMORY_ERROR: "Chyba pamäte" +STR_PREVIEW_UNAVAILABLE: "Ukážka nie je dostupná" STR_PAGE_LOAD_ERROR: "Chyba načítania stránky" STR_EMPTY_FILE: "Prázdny súbor" STR_OUT_OF_BOUNDS: "Mimo rozsahu" @@ -326,7 +327,7 @@ STR_BITTER: "Bitter" STR_CHAREINK: "ChareInk" STR_ATKINSON_HL: "Atkinson Hyperlegible" STR_FILTER_CONTRAST: "Kontrast" -STR_CUSTOMISE_STATUS_BAR: "Uprav status bar" +STR_STATUS_BARS: "Stavové riadky" STR_CHAPTER_PAGE_COUNT: "Počítadlo strán kapitoly" STR_BOOK_PROGRESS_PERCENTAGE: "Prečítané percent" STR_PERCENTAGE_FORMAT: "Formát percent" @@ -636,6 +637,7 @@ STR_CUSTOM_BOOTSCREEN: "Vlastná úvodná obrazovka" STR_TAP_HIDE_STATUS_BAR: "Ťuknutím skryť stavový riadok" STR_LIBRARY_SORT_BY: "Zoradiť podľa:" +STR_SORT: "Zoradiť" STR_LIBRARY_DATE_ADDED: "Dátum pridania" STR_LIBRARY_SERIES: "Séria" STR_LIBRARY_GENRE: "Žáner" @@ -674,9 +676,19 @@ STR_LIBRARY_SETTINGS: "Nastavenia knižnice" STR_LIBRARY_LIST_VIEW: "Zobrazenie zoznamu" STR_LIBRARY_EXPANDED: "Rozšírené" STR_LIBRARY_SHOW_FILES: "Zobraziť súbory" +STR_LIBRARY_FILES_COUNT: "%u súborov" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Kompaktné" STR_LIBRARY_FINISHED_BOOKS: "Dočítané knihy" + +STR_TOP_STATUS_BAR: "Horný stavový riadok" +STR_BOTTOM_STATUS_BAR: "Dolný stavový riadok" +STR_STATUS_BAR_LEFT: "Vľavo" +STR_STATUS_BAR_RIGHT: "Vpravo" +STR_STATUS_BAR_EMPTY: "Prázdne" +STR_STATUS_BAR_CLOCK: "Hodiny" +STR_STATUS_BAR_BOTH: "Oba" +STR_CLOCK_OUTSIDE_READER: "Hodiny mimo čítačky" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index ad14a91545f..1a2d3b5a888 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Konec knjige" STR_EMPTY_CHAPTER: "Prazno poglavje" STR_INDEXING: "Indeksiranje" STR_MEMORY_ERROR: "Napaka pomnilnika" +STR_PREVIEW_UNAVAILABLE: "Predogled ni na voljo" STR_PAGE_LOAD_ERROR: "Napaka pri nalaganju strani" STR_EMPTY_FILE: "Prazna datoteka" STR_OUT_OF_BOUNDS: "Izven meja" @@ -295,7 +296,7 @@ STR_DIR_DOWN: "Dol" STR_OK_BUTTON: "V redu" STR_SLEEP_COVER_FILTER: "Filter naslovnice v spanju" STR_FILTER_CONTRAST: "Kontrast" -STR_CUSTOMISE_STATUS_BAR: "Prilagodi vrstico stanja" +STR_STATUS_BARS: "Vrstice stanja" STR_CHAPTER_PAGE_COUNT: "Število strani v poglavju" STR_BOOK_PROGRESS_PERCENTAGE: "Odstotek napredka v knjigi" STR_PERCENTAGE_FORMAT: "Oblika odstotka" @@ -540,6 +541,7 @@ STR_TAP_HIDE_STATUS_BAR: "Dotik skrije vrstico stanja" STR_PREV_PAGE: "Prejšnja stran" STR_LIBRARY_SORT_BY: "Razvrsti po:" +STR_SORT: "Razvrsti" STR_LIBRARY_DATE_ADDED: "Datum dodajanja" STR_LIBRARY_SERIES: "Serija" STR_LIBRARY_GENRE: "Žanr" @@ -578,9 +580,19 @@ STR_LIBRARY_SETTINGS: "Nastavitve knjižnice" STR_LIBRARY_LIST_VIEW: "Pogled seznama" STR_LIBRARY_EXPANDED: "Razširjen" STR_LIBRARY_SHOW_FILES: "Prikaži datoteke" +STR_LIBRARY_FILES_COUNT: "%u datotek" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Strnjen" STR_LIBRARY_FINISHED_BOOKS: "Prebrane knjige" + +STR_TOP_STATUS_BAR: "Zgornja vrstica stanja" +STR_BOTTOM_STATUS_BAR: "Spodnja vrstica stanja" +STR_STATUS_BAR_LEFT: "Levo" +STR_STATUS_BAR_RIGHT: "Desno" +STR_STATUS_BAR_EMPTY: "Prazno" +STR_STATUS_BAR_CLOCK: "Ura" +STR_STATUS_BAR_BOTH: "Obe" +STR_CLOCK_OUTSIDE_READER: "Ura zunaj bralnika" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 4298ab6974d..dadab4af71f 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -29,6 +29,7 @@ STR_END_OF_BOOK: "Fin del libro" STR_EMPTY_CHAPTER: "Capítulo vacío" STR_INDEXING: "Indexando" STR_MEMORY_ERROR: "Error de memoria" +STR_PREVIEW_UNAVAILABLE: "Vista previa no disponible" STR_PAGE_LOAD_ERROR: "Error al cargar la página" STR_EMPTY_FILE: "Archivo vacío" STR_OUT_OF_BOUNDS: "Fuera de rango" @@ -344,7 +345,7 @@ STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtro de pantalla de suspensión" STR_SLEEP_COVER_FILTER_SHORT: "Filtro de portada" STR_FILTER_CONTRAST: "Contraste" -STR_CUSTOMISE_STATUS_BAR: "Personalizar barra de estado" +STR_STATUS_BARS: "Barras de estado" STR_CHAPTER_PAGE_COUNT: "Contador de pág. por cap." STR_STABLE_PAGE_NUMBERS: "Números de página estables" STR_BOOK_PROGRESS_PERCENTAGE: "Porcentaje progreso libro" @@ -716,6 +717,7 @@ STR_CUSTOM_BOOTSCREEN: "Pantalla de inicio propia" STR_TAP_HIDE_STATUS_BAR: "Tocar para ocultar el estado" STR_LIBRARY_SORT_BY: "Ordenar por:" +STR_SORT: "Ordenar" STR_LIBRARY_DATE_ADDED: "Fecha de adición" STR_LIBRARY_SERIES: "Serie" STR_LIBRARY_GENRE: "Género" @@ -754,8 +756,18 @@ STR_LIBRARY_SETTINGS: "Ajustes de biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de lista" STR_LIBRARY_EXPANDED: "Ampliada" STR_LIBRARY_SHOW_FILES: "Mostrar archivos" +STR_LIBRARY_FILES_COUNT: "%u archivos" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_LIBRARY_FINISHED_BOOKS: "Libros terminados" + +STR_TOP_STATUS_BAR: "Barra superior" +STR_BOTTOM_STATUS_BAR: "Barra inferior" +STR_STATUS_BAR_LEFT: "Izquierda" +STR_STATUS_BAR_RIGHT: "Derecha" +STR_STATUS_BAR_EMPTY: "Vacío" +STR_STATUS_BAR_CLOCK: "Reloj" +STR_STATUS_BAR_BOTH: "Ambas" +STR_CLOCK_OUTSIDE_READER: "Reloj fuera del lector" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index f83c42787d7..60255d6c405 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Slutet på boken" STR_EMPTY_CHAPTER: "Tomt kapitel" STR_INDEXING: "Indexerar" STR_MEMORY_ERROR: "Minnesfel" +STR_PREVIEW_UNAVAILABLE: "Förhandsvisning inte tillgänglig" STR_PAGE_LOAD_ERROR: "Sidladdningsfel" STR_EMPTY_FILE: "Tom fil" STR_OUT_OF_BOUNDS: "Utanför gränserna" @@ -302,7 +303,7 @@ STR_DIR_DOWN: "Ner" STR_OK_BUTTON: "Okej" STR_SLEEP_COVER_FILTER: "Viloskärmens omslagsfilter" STR_FILTER_CONTRAST: "Kontrast" -STR_CUSTOMISE_STATUS_BAR: "Anpassa statusfält" +STR_STATUS_BARS: "Statusfält" STR_CHAPTER_PAGE_COUNT: "Antal sidor i kapitel" STR_BOOK_PROGRESS_PERCENTAGE: "Procentuellt bokframsteg" STR_PERCENTAGE_FORMAT: "Procentformat" @@ -595,6 +596,7 @@ STR_CUSTOM_BOOTSCREEN: "Egen startskärm" STR_TAP_HIDE_STATUS_BAR: "Tryck döljer statusfältet" STR_LIBRARY_SORT_BY: "Sortera efter:" +STR_SORT: "Sortera" STR_LIBRARY_DATE_ADDED: "Datum tillagd" STR_LIBRARY_SERIES: "Serie" STR_LIBRARY_GENRE: "Genre" @@ -633,9 +635,19 @@ STR_LIBRARY_SETTINGS: "Biblioteksinställningar" STR_LIBRARY_LIST_VIEW: "Listvy" STR_LIBRARY_EXPANDED: "Utökad" STR_LIBRARY_SHOW_FILES: "Visa filer" +STR_LIBRARY_FILES_COUNT: "%u filer" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Kompakt" STR_LIBRARY_FINISHED_BOOKS: "Färdiglästa böcker" + +STR_TOP_STATUS_BAR: "Övre statusfält" +STR_BOTTOM_STATUS_BAR: "Nedre statusfält" +STR_STATUS_BAR_LEFT: "Vänster" +STR_STATUS_BAR_RIGHT: "Höger" +STR_STATUS_BAR_EMPTY: "Tom" +STR_STATUS_BAR_CLOCK: "Klocka" +STR_STATUS_BAR_BOTH: "Båda" +STR_CLOCK_OUTSIDE_READER: "Klocka utanför läsaren" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 51c5332dba2..a16a531e769 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -32,6 +32,7 @@ STR_END_OF_BOOK: "Kitabın sonu" STR_EMPTY_CHAPTER: "Boş bölüm" STR_INDEXING: "Endeksleniyor" STR_MEMORY_ERROR: "Bellek hatası" +STR_PREVIEW_UNAVAILABLE: "Önizleme kullanılamıyor" STR_PAGE_LOAD_ERROR: "Sayfa yükleme hatası" STR_EMPTY_FILE: "Boş dosya" STR_OUT_OF_BOUNDS: "Sınırların dışında" @@ -374,7 +375,7 @@ STR_BOOK_PROGRESS_PERCENTAGE: "Kitap İlerleme Yüzdesi" STR_PERCENTAGE_FORMAT: "Yüzde biçimi" STR_CHAPTER: "Bölüm" STR_CHAPTER_PAGE_COUNT: "Bölüm Sayfa Sayısı" -STR_CUSTOMISE_STATUS_BAR: "Durum Çubuğunu Özelleştir" +STR_STATUS_BARS: "Durum Çubukları" STR_DELETE: "Sil" STR_DISPLAY_QR: "Sayfayı QR olarak göster" STR_EXAMPLE_BOOK: "Kitap Başlığı" @@ -539,6 +540,7 @@ STR_TAP_HIDE_STATUS_BAR: "Dokunarak durum çubuğunu gizle" STR_PREV_PAGE: "Önceki sayfa" STR_LIBRARY_SORT_BY: "Sıralama:" +STR_SORT: "Sırala" STR_LIBRARY_DATE_ADDED: "Eklenme tarihi" STR_LIBRARY_SERIES: "Seri" STR_LIBRARY_GENRE: "Tür" @@ -577,9 +579,19 @@ STR_LIBRARY_SETTINGS: "Kitaplık ayarları" STR_LIBRARY_LIST_VIEW: "Liste görünümü" STR_LIBRARY_EXPANDED: "Genişletilmiş" STR_LIBRARY_SHOW_FILES: "Dosyaları göster" +STR_LIBRARY_FILES_COUNT: "%u dosya" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Kompakt" STR_LIBRARY_FINISHED_BOOKS: "Bitirilen kitaplar" + +STR_TOP_STATUS_BAR: "Üst durum çubuğu" +STR_BOTTOM_STATUS_BAR: "Alt durum çubuğu" +STR_STATUS_BAR_LEFT: "Sol" +STR_STATUS_BAR_RIGHT: "Sağ" +STR_STATUS_BAR_EMPTY: "Boş" +STR_STATUS_BAR_CLOCK: "Saat" +STR_STATUS_BAR_BOTH: "İkisi" +STR_CLOCK_OUTSIDE_READER: "Okuyucu dışı saat" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 9a4764ab47d..24b9a2133a9 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Кінець книги" STR_EMPTY_CHAPTER: "Порожній розділ" STR_INDEXING: "Індексація" STR_MEMORY_ERROR: "Помилка пам'яті" +STR_PREVIEW_UNAVAILABLE: "Попередній перегляд недоступний" STR_PAGE_LOAD_ERROR: "Помилка завантаження сторінки" STR_EMPTY_FILE: "Порожній файл" STR_OUT_OF_BOUNDS: "Поза межами" @@ -300,7 +301,7 @@ STR_DIR_DOWN: "Вниз" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Ефект заставки" STR_FILTER_CONTRAST: "Контраст" -STR_CUSTOMISE_STATUS_BAR: "Налаштувати рядок стану" +STR_STATUS_BARS: "Рядки стану" STR_CHAPTER_PAGE_COUNT: "Кількість сторінок у розділі" STR_BOOK_PROGRESS_PERCENTAGE: "Відсоток прочитаного" STR_PERCENTAGE_FORMAT: "Формат відсотків" @@ -593,6 +594,7 @@ STR_CUSTOM_BOOTSCREEN: "Власний екран запуску" STR_TAP_HIDE_STATUS_BAR: "Дотик ховає рядок стану" STR_LIBRARY_SORT_BY: "Сортувати:" +STR_SORT: "Сортувати" STR_LIBRARY_DATE_ADDED: "Дата додавання" STR_LIBRARY_SERIES: "Серія" STR_LIBRARY_GENRE: "Жанр" @@ -631,9 +633,19 @@ STR_LIBRARY_SETTINGS: "Налаштування бібліотеки" STR_LIBRARY_LIST_VIEW: "Вигляд списку" STR_LIBRARY_EXPANDED: "Розгорнутий" STR_LIBRARY_SHOW_FILES: "Показувати файли" +STR_LIBRARY_FILES_COUNT: "%u файлів" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Компактний" STR_LIBRARY_FINISHED_BOOKS: "Прочитані книги" + +STR_TOP_STATUS_BAR: "Верхній рядок стану" +STR_BOTTOM_STATUS_BAR: "Нижній рядок стану" +STR_STATUS_BAR_LEFT: "Ліворуч" +STR_STATUS_BAR_RIGHT: "Праворуч" +STR_STATUS_BAR_EMPTY: "Порожньо" +STR_STATUS_BAR_CLOCK: "Годинник" +STR_STATUS_BAR_BOTH: "Обидва" +STR_CLOCK_OUTSIDE_READER: "Годинник поза читанням" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index 437a558bae0..c6c6ce454fd 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Final del llibre" STR_EMPTY_CHAPTER: "Capítol buit" STR_INDEXING: "S'està indexant" STR_MEMORY_ERROR: "Error de memòria" +STR_PREVIEW_UNAVAILABLE: "Previsualització no disponible" STR_PAGE_LOAD_ERROR: "Error en carregar la pàgina" STR_EMPTY_FILE: "Arxiu buit" STR_OUT_OF_BOUNDS: "Fora de límits" @@ -298,7 +299,7 @@ STR_DIR_DOWN: "Avall" STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtre de pantalla de repòs" STR_FILTER_CONTRAST: "Contrast" -STR_CUSTOMISE_STATUS_BAR: "Personalitza la barra d'estat" +STR_STATUS_BARS: "Barres d’estat" STR_CHAPTER_PAGE_COUNT: "Comptador de pàgines del capítol" STR_BOOK_PROGRESS_PERCENTAGE: "Percentatge de progrés del llibre" STR_PERCENTAGE_FORMAT: "Format del percentatge" @@ -580,6 +581,7 @@ STR_CUSTOM_BOOTSCREEN: "Pantalla d’inici pròpia" STR_TAP_HIDE_STATUS_BAR: "Toca per a amagar l’estat" STR_LIBRARY_SORT_BY: "Ordena per:" +STR_SORT: "Ordena" STR_LIBRARY_DATE_ADDED: "Data d’addició" STR_LIBRARY_SERIES: "Sèrie" STR_LIBRARY_GENRE: "Gènere" @@ -619,9 +621,19 @@ STR_LIBRARY_SETTINGS: "Configuració de la biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de llista" STR_LIBRARY_EXPANDED: "Ampliada" STR_LIBRARY_SHOW_FILES: "Mostra fitxers" +STR_LIBRARY_FILES_COUNT: "%u fitxers" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Compacta" STR_LIBRARY_FINISHED_BOOKS: "Llibres acabats" + +STR_TOP_STATUS_BAR: "Barra d’estat superior" +STR_BOTTOM_STATUS_BAR: "Barra d’estat inferior" +STR_STATUS_BAR_LEFT: "Esquerra" +STR_STATUS_BAR_RIGHT: "Dreta" +STR_STATUS_BAR_EMPTY: "Buit" +STR_STATUS_BAR_CLOCK: "Rellotge" +STR_STATUS_BAR_BOTH: "Ambdues" +STR_CLOCK_OUTSIDE_READER: "Rellotge fora del lector" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index c8852d31274..12a47705e56 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -33,6 +33,7 @@ STR_END_OF_BOOK: "Cuối sách" STR_EMPTY_CHAPTER: "Chương trống" STR_INDEXING: "Đang lập chỉ mục" STR_MEMORY_ERROR: "Lỗi bộ nhớ" +STR_PREVIEW_UNAVAILABLE: "Không thể xem trước" STR_PAGE_LOAD_ERROR: "Lỗi tải trang" STR_EMPTY_FILE: "Tệp trống" STR_OUT_OF_BOUNDS: "Vượt phạm vi" @@ -331,7 +332,7 @@ STR_BITTER: "Bitter" STR_CHAREINK: "ChareInk" STR_ATKINSON_HL: "Atkinson Hyperlegible" STR_FILTER_CONTRAST: "Tương phản" -STR_CUSTOMISE_STATUS_BAR: "Tùy chỉnh thanh trạng thái" +STR_STATUS_BARS: "Các thanh trạng thái" STR_CHAPTER_PAGE_COUNT: "Số trang chương" STR_BOOK_PROGRESS_PERCENTAGE: "Phần trăm tiến độ sách" STR_PERCENTAGE_FORMAT: "Định dạng phần trăm" @@ -597,6 +598,7 @@ STR_CUSTOM_BOOTSCREEN: "Màn hình khởi động riêng" STR_TAP_HIDE_STATUS_BAR: "Chạm để ẩn thanh trạng thái" STR_LIBRARY_SORT_BY: "Sắp xếp theo:" +STR_SORT: "Sắp xếp" STR_LIBRARY_DATE_ADDED: "Ngày thêm" STR_LIBRARY_SERIES: "Bộ sách" STR_LIBRARY_GENRE: "Thể loại" @@ -636,9 +638,19 @@ STR_LIBRARY_SETTINGS: "Cài đặt thư viện" STR_LIBRARY_LIST_VIEW: "Chế độ xem danh sách" STR_LIBRARY_EXPANDED: "Mở rộng" STR_LIBRARY_SHOW_FILES: "Hiển thị tệp" +STR_LIBRARY_FILES_COUNT: "%u tệp" STR_LIBRARY_EPUBS: "EPUB" STR_LIBRARY_XTC_XTCH: "XTC/XTCH" STR_LIBRARY_TXT: "TXT" STR_LIBRARY_MARKDOWN: "Markdown" STR_COMPACT: "Thu gọn" STR_LIBRARY_FINISHED_BOOKS: "Sách đã đọc xong" + +STR_TOP_STATUS_BAR: "Thanh trạng thái trên" +STR_BOTTOM_STATUS_BAR: "Thanh trạng thái dưới" +STR_STATUS_BAR_LEFT: "Trái" +STR_STATUS_BAR_RIGHT: "Phải" +STR_STATUS_BAR_EMPTY: "Trống" +STR_STATUS_BAR_CLOCK: "Đồng hồ" +STR_STATUS_BAR_BOTH: "Cả hai" +STR_CLOCK_OUTSIDE_READER: "Đồng hồ ngoài trình đọc" diff --git a/lib/LibraryIndex/LibraryBuilder.cpp b/lib/LibraryIndex/LibraryBuilder.cpp index 0bde05536bf..a09202a791e 100644 --- a/lib/LibraryIndex/LibraryBuilder.cpp +++ b/lib/LibraryIndex/LibraryBuilder.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,7 @@ #include #include +#include "../../src/util/BookCacheUtils.h" #include "LibraryFileTypes.h" #include "LibraryIndexFile.h" #include "LibraryText.h" @@ -467,6 +469,11 @@ int findPrior(WalkState& st, const uint64_t pathHash) { modificationTime == 0 || (priorIndex >= 0 && (st.prior[priorIndex].fileSize != fileSize || priorRecord.modificationTime == 0 || priorRecord.modificationTime != modificationTime)); + if (sourceChanged && !clearBookCachePreservingUserState(fullPath)) { + LOG_ERR("LIBIDX", "Cannot invalidate stale EPUB cache while preserving reading state: %s", fullPath.c_str()); + st.failed = true; + return false; + } if (epub.loadMetadata(bookTitle, author, !sourceChanged, &series, &genre, &seriesIndex)) { entry.record.metadataStatus = CLIX_METADATA_EXTRACTED; entry.seriesPosition = parseSeriesPosition(seriesIndex); @@ -1045,7 +1052,7 @@ bool emitIndex(const char* folderStagePath, WalkState& st, const uint16_t* order // has to be SORTED rather than assumed, or "Recent" silently degrades into // "the order the card enumerates in" — exactly the bug reconciliation exists // to prevent. - bool arrivalDegraded = false; + stats.arrivalDegraded = false; if (rankable) { for (uint16_t i = 0; i < n; i++) arrivalOrder[i] = i; if (n > 1) { @@ -1062,7 +1069,7 @@ bool emitIndex(const char* folderStagePath, WalkState& st, const uint16_t* order } } } else { - arrivalDegraded = true; + stats.arrivalDegraded = true; LOG_ERR("LIBIDX", "OOM: %u-byte creation-time array, arrival falls back to firstSeen", static_cast(n * sizeof(uint32_t))); } @@ -1288,7 +1295,7 @@ bool emitIndex(const char* folderStagePath, WalkState& st, const uint16_t* order header.flags = (stats.ranksDegraded ? CLIX_FLAG_RANKS_DEGRADED : 0) | (stats.dedupDegraded ? CLIX_FLAG_DEDUP_DEGRADED : 0) | - (arrivalDegraded ? CLIX_FLAG_ARRIVAL_DEGRADED : 0); + (stats.arrivalDegraded ? CLIX_FLAG_ARRIVAL_DEGRADED : 0); if (!out.seekSet(0)) { ioFailed = true; @@ -1321,8 +1328,8 @@ bool emitIndex(const char* folderStagePath, WalkState& st, const uint16_t* order const char* libraryIndexPath() { return INDEX_PATH; } -bool buildLibraryIndex(const char* rootPath, BuildStats& stats, const bool readMetadata, const BuildControl* control, - const bool retryFailedMetadata) { +static bool rebuildLibraryIndex(const char* rootPath, BuildStats& stats, const bool readMetadata, + const BuildControl* control, const bool retryFailedMetadata) { const uint32_t startMs = millis(); uint32_t serviceUnits = 0; stats = BuildStats{}; @@ -1451,9 +1458,10 @@ bool buildLibraryIndex(const char* rootPath, BuildStats& stats, const bool readM stats.enriched = st.enriched; if (previous.isOpen() && previous.header().formatVersion == CLIX_FORMAT_VERSION && - previous.header().foldVersion == CLIX_FOLD_VERSION && st.books == priorCount && st.reused == priorCount && - stats.metadataReused == priorCount && st.creationTimesUnchanged && st.unreadableSkipped == 0 && - !st.dedupDegraded && + previous.header().foldVersion == CLIX_FOLD_VERSION && + previous.header().metadataEnabled == static_cast(readMetadata) && st.books == priorCount && + st.reused == priorCount && stats.metadataReused == priorCount && st.creationTimesUnchanged && + st.unreadableSkipped == 0 && !st.dedupDegraded && (previous.header().flags & (CLIX_FLAG_RANKS_DEGRADED | CLIX_FLAG_DEDUP_DEGRADED | CLIX_FLAG_ARRIVAL_DEGRADED)) == 0) { previous.close(); @@ -1667,4 +1675,24 @@ bool buildLibraryIndex(const char* rootPath, BuildStats& stats, const bool readM return ok; } +namespace { +std::atomic indexDirty{true}; +} + +void invalidateLibraryIndex() { indexDirty.store(true, std::memory_order_relaxed); } + +bool libraryIndexNeedsRefresh() { return indexDirty.load(std::memory_order_relaxed); } + +bool buildLibraryIndex(const char* rootPath, BuildStats& stats, const bool readMetadata, const BuildControl* control, + const bool retryFailedMetadata) { + // Clear before scanning, not after: a file mutation during the scan must + // survive as a request for another reconciliation. A cancelled background + // build returns false, which marks the index dirty again. + indexDirty.exchange(false, std::memory_order_relaxed); + const bool ok = rebuildLibraryIndex(rootPath, stats, readMetadata, control, retryFailedMetadata); + if (!ok || stats.unreadableSkipped || stats.ranksDegraded || stats.dedupDegraded || stats.arrivalDegraded) + invalidateLibraryIndex(); + return ok; +} + } // namespace library diff --git a/lib/LibraryIndex/LibraryBuilder.h b/lib/LibraryIndex/LibraryBuilder.h index f6fd351f072..7cbb8818a5b 100644 --- a/lib/LibraryIndex/LibraryBuilder.h +++ b/lib/LibraryIndex/LibraryBuilder.h @@ -53,6 +53,7 @@ struct BuildStats { bool indexReplaced = false; bool ranksDegraded = false; bool dedupDegraded = false; + bool arrivalDegraded = false; bool cancelled = false; // the owner stopped the build; the previous index is untouched }; @@ -78,6 +79,12 @@ struct BuildControl { bool buildLibraryIndex(const char* rootPath, BuildStats& stats, bool readMetadata = false, const BuildControl* control = nullptr, bool retryFailedMetadata = false); +// Starts dirty on every boot to reconcile external card edits. File-changing +// activities must invalidate before returning to Library. A successful scan +// clears only changes known when it started; failures remain retryable. +void invalidateLibraryIndex(); +bool libraryIndexNeedsRefresh(); + // Live index path, shared by the builder and activity. const char* libraryIndexPath(); diff --git a/lib/hal/HalClock.cpp b/lib/hal/HalClock.cpp index 29a5089c07a..b4b37c638b7 100644 --- a/lib/hal/HalClock.cpp +++ b/lib/hal/HalClock.cpp @@ -1,5 +1,6 @@ #include "HalClock.h" +#include #include #include #include @@ -15,10 +16,6 @@ namespace { constexpr uint16_t kBaseYear = 2000; // An RTC reading before this year was never synced or has been reset. constexpr uint16_t kMinTrustedYear = 2025; -constexpr const char* kMonthNames[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; -constexpr char kFullMonthNames[][10] = {"January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December"}; esp_err_t clearDnsCache(void*) { dns_clear_cache(); @@ -188,41 +185,7 @@ bool HalClock::formatDate(char* buf, size_t bufSize, uint8_t utcOffsetQuarterHou adjustDateByDays(year, month, day, dayDelta); if (!isValidDate(year, month, day)) return false; - const unsigned int displayMonth = month; - const unsigned int displayDay = day; - const unsigned int displayYear = year; - const char separator = numericSeparator == '.' || numericSeparator == '-' ? numericSeparator : '/'; - switch (dateFormat) { - case DAY_MONTH_YEAR_LONG: - snprintf(buf, bufSize, "%02u %s %u", displayDay, kMonthNames[month - 1], displayYear); - break; - case MONTH_DAY_YEAR_NUMERIC: - snprintf(buf, bufSize, "%02u%c%02u%c%u", displayMonth, separator, displayDay, separator, displayYear); - break; - case DAY_MONTH_YEAR_NUMERIC: - snprintf(buf, bufSize, "%02u%c%02u%c%u", displayDay, separator, displayMonth, separator, displayYear); - break; - case YEAR_MONTH_DAY_NUMERIC: - snprintf(buf, bufSize, "%u%c%02u%c%02u", displayYear, separator, displayMonth, separator, displayDay); - break; - case MONTH_DAY_NUMERIC: - snprintf(buf, bufSize, "%02u%c%02u", displayMonth, separator, displayDay); - break; - case DAY_MONTH_NUMERIC: - snprintf(buf, bufSize, "%02u%c%02u", displayDay, separator, displayMonth); - break; - case MONTH_DAY_LONG: - snprintf(buf, bufSize, "%s %02u", kFullMonthNames[month - 1], displayDay); - break; - case DAY_MONTH_LONG: - snprintf(buf, bufSize, "%02u %s", displayDay, kFullMonthNames[month - 1]); - break; - case MONTH_DAY_YEAR_LONG: - default: - snprintf(buf, bufSize, "%s %02u, %u", kMonthNames[month - 1], displayDay, displayYear); - break; - } - return true; + return formatDateParts(buf, bufSize, year, month, day, dateFormat, numericSeparator); } bool HalClock::writeDateTimeToRTC(uint16_t year, uint8_t month, uint8_t day, uint8_t weekday, uint8_t hour, diff --git a/scripts/run_simulator_smoke_test.py b/scripts/run_simulator_smoke_test.py index 85c89024e72..81955cef8f1 100755 --- a/scripts/run_simulator_smoke_test.py +++ b/scripts/run_simulator_smoke_test.py @@ -131,7 +131,7 @@ def run_smoke(args: argparse.Namespace) -> int: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--book", default=str(DEFAULT_BOOK), help="EPUB fixture to copy into the isolated simulator fs_") - parser.add_argument("--env", choices=("simulator", "sticky-simulator", "x4-pro-simulator"), default="simulator", + parser.add_argument("--env", choices=("simulator", "simulator-X3", "x4-classic-simulator", "sticky-simulator", "x4-pro-simulator"), default="simulator", help="PlatformIO simulator environment to build and run") parser.add_argument("--font-dir", help="Font fixtures copied into isolated /fonts") parser.add_argument("--font-family", help="Exercise custom-font size and dictionary lifecycle") diff --git a/scripts/test_reader_drawer.py b/scripts/test_reader_drawer.py index a77b33be5c4..233dbe86e8a 100644 --- a/scripts/test_reader_drawer.py +++ b/scripts/test_reader_drawer.py @@ -59,7 +59,7 @@ def check_drawer(program: Path, orientation: int) -> None: CROSSPOINT_SIM_SCREENSHOTS=f"5000:{screenshot}") result = subprocess.run([str(program)], cwd=work, env=env, capture_output=True, text=True, timeout=20) output = result.stdout + result.stderr - if result.returncode or "Entering activity: EpubReaderTouchMenu" not in output: + if result.returncode or "Entering activity: EpubReaderDrawer" not in output: raise AssertionError(f"Reader drawer did not open\n{output}") groups = rule_groups(screenshot) if groups != 1: diff --git a/src/BookmarkStore.cpp b/src/BookmarkStore.cpp index f4f47029122..b7fc3701a21 100644 --- a/src/BookmarkStore.cpp +++ b/src/BookmarkStore.cpp @@ -850,7 +850,7 @@ bool BookmarkStore::rollbackRenameMigration(RenameMigration& migration) { bool BookmarkStore::hasAnyBookmarks() { if (!Storage.exists(BOOKMARKS_DIR)) return false; - return !Storage.listFiles(BOOKMARKS_DIR).empty(); + return !Storage.listFiles(BOOKMARKS_DIR, 1).empty(); } bool BookmarkStore::getAllBookmarkedBooks(std::vector& out) { diff --git a/src/ClippingStore.cpp b/src/ClippingStore.cpp index 1958d8788da..8ec1e2679cf 100644 --- a/src/ClippingStore.cpp +++ b/src/ClippingStore.cpp @@ -490,7 +490,7 @@ bool ClippingStore::writeToFile(const std::string* replacementText, const size_t bool ClippingStore::hasAnyClippings() { if (!Storage.exists(CLIPPINGS_DIR)) return false; - return !Storage.listFiles(CLIPPINGS_DIR).empty(); + return !Storage.listFiles(CLIPPINGS_DIR, 1).empty(); } bool ClippingStore::getAllClippedBooks(std::vector& out) { diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 080d86c4ffa..2d08953b3eb 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -24,6 +24,7 @@ #include "SettingsList.h" #include "fontIds.h" #include "util/FrontlightSchedule.h" +#include "util/ReaderStatusBarJson.h" #include "util/TwoFingerSwipe.h" void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) { @@ -230,7 +231,8 @@ bool isValidQuickActionSlot(const uint8_t action) { return action < CrossPointSettings::QUICK_ACTION_SLOT_ACTION_COUNT || action == CrossPointSettings::TOGGLE_HOME_BUTTON_IN_READER || action == CrossPointSettings::TOGGLE_FRONTLIGHT || action == CrossPointSettings::TOGGLE_TOUCHSCREEN || - action == CrossPointSettings::PREVIOUS_PAGE || action == CrossPointSettings::NEARBY_POSITION_SYNC; + action == CrossPointSettings::PREVIOUS_PAGE || action == CrossPointSettings::NEARBY_POSITION_SYNC || + action == CrossPointSettings::LIBRARY; } uint8_t migrateTiltDirectionValue(const uint8_t direction) { @@ -458,6 +460,13 @@ void CrossPointSettings::toJson(JsonDocument& doc) const { } } + JsonObject bars = doc["readerStatusBars"].to(); + bars["version"] = 1; + writeReaderStatusBarJson(bars["top"].to(), topReaderStatusBar); + writeReaderStatusBarJson(bars["bottom"].to(), bottomReaderStatusBar); + bars["xtcMode"] = xtcStatusBarMode; + bars["legacyXtcTopUsesBottom"] = legacyXtcTopUsesBottom != 0; + // Library-local choices stay out of the resident settings catalog and Web Settings. doc["librarySortMethod"] = librarySortMethod; doc["librarySortDescending"] = librarySortDescending; @@ -726,6 +735,36 @@ bool CrossPointSettings::fromJson(JsonVariantConst doc, bool importingCrossPoint hideClock = legacyShowClock == LEGACY_SHOW_CLOCK_NEVER ? HIDE_CLOCK_ALWAYS : HIDE_CLOCK_NEVER; needsResave = true; } + if (doc["showClockOutsideReader"].isNull()) { + showClockOutsideReader = hideClock != HIDE_CLOCK_ALWAYS; + needsResave = true; + } + const JsonVariantConst bars = doc["readerStatusBars"]; + if (bars["version"] != 1) { + bottomReaderStatusBar = migrateBottomStatusBar( + {statusBarChapterPageCount != 0, stablePageNumbers != 0, statusBarBookProgressPercentage != 0, statusBarTitle, + statusBarTimeLeft, statusBarBattery != 0, statusBarBookPercentageFormat, statusBarProgressBar, + statusBarProgressBarThickness}); + if (halClock.isAvailable() && hideClock == HIDE_CLOCK_NEVER) { + topReaderStatusBar.slots[ReaderStatusBarConfig::CENTER] = ReaderStatusBarItem::Clock; + } + legacyXtcTopUsesBottom = xtcStatusBarMode == XTC_STATUS_BAR_TOP; + needsResave = true; + } else { + needsResave |= + repairReaderStatusBarJson(bars["top"], topReaderStatusBar, halClock.isAvailable(), BOOK_PERCENTAGE_FORMAT_COUNT, + STATUS_BAR_PROGRESS_BAR_COUNT, STATUS_BAR_PROGRESS_BAR_THICKNESS_COUNT); + needsResave |= repairReaderStatusBarJson(bars["bottom"], bottomReaderStatusBar, halClock.isAvailable(), + BOOK_PERCENTAGE_FORMAT_COUNT, STATUS_BAR_PROGRESS_BAR_COUNT, + STATUS_BAR_PROGRESS_BAR_THICKNESS_COUNT); + const int xtcMode = bars["xtcMode"].as(); + if (bars["xtcMode"].is() && xtcMode >= 0 && xtcMode < XTC_STATUS_BAR_MODE_COUNT) { + xtcStatusBarMode = xtcMode; + } else { + needsResave = true; + } + legacyXtcTopUsesBottom = bars["legacyXtcTopUsesBottom"].as() ? 1 : 0; + } if (doc["sleepTimeoutMinutes"].isNull() && !doc["sleepTimeout"].isNull()) { const uint8_t legacyValue = clamp(doc["sleepTimeout"] | static_cast(SLEEP_10_MIN), SLEEP_TIMEOUT_COUNT, SLEEP_10_MIN); @@ -872,6 +911,11 @@ bool CrossPointSettings::loadFromFile() { // Fall back to binary migration if (Storage.exists(SETTINGS_FILE_BIN)) { if (loadFromBinaryFile()) { + applyLegacyStatusBarSettings(*this); + bottomReaderStatusBar = migrateBottomStatusBar( + {statusBarChapterPageCount != 0, stablePageNumbers != 0, statusBarBookProgressPercentage != 0, statusBarTitle, + statusBarTimeLeft, statusBarBattery != 0, statusBarBookPercentageFormat, statusBarProgressBar, + statusBarProgressBarThickness}); migrateLanguageBinaryFile(); if (saveToFile()) { Storage.rename(SETTINGS_FILE_BIN, SETTINGS_FILE_BAK); @@ -1023,21 +1067,26 @@ bool CrossPointSettings::loadFromBinaryFile() { return true; } -CrossPointSettings::StatusBarSpec CrossPointSettings::statusBarSpec() const { - StatusBarSpec spec; - spec.showChapterPageCount = statusBarChapterPageCount != 0; - spec.showBookProgressPercent = statusBarBookProgressPercentage != 0; - spec.showStablePageNumbers = stablePageNumbers != 0; - spec.titleMode = statusBarTitle; - spec.timeLeftMode = statusBarTimeLeft; - spec.showBattery = statusBarBattery != 0; - spec.showBatteryPercent = hideBatteryPercentage == HIDE_NEVER; - spec.showClock = hideClock == HIDE_CLOCK_NEVER; - spec.progressBarMode = statusBarProgressBar; - spec.progressBarHeightPx = - statusBarProgressBar != HIDE_PROGRESS ? static_cast((statusBarProgressBarThickness + 1) * 2) : 0; - spec.xtcMode = xtcStatusBarMode; - return spec; +ReaderStatusBarConfig CrossPointSettings::readerStatusBar(const ReaderStatusBarPosition position) const { + std::lock_guard lock(_mutex); + return position == ReaderStatusBarPosition::Top ? topReaderStatusBar : bottomReaderStatusBar; +} + +bool CrossPointSettings::parseReaderStatusBars(JsonVariantConst json, ReaderStatusBarsPayload& config) { + return readReaderStatusBarsPayload(json, config, halClock.isAvailable(), BOOK_PERCENTAGE_FORMAT_COUNT, + STATUS_BAR_PROGRESS_BAR_COUNT, STATUS_BAR_PROGRESS_BAR_THICKNESS_COUNT, + XTC_STATUS_BAR_MODE_COUNT); +} + +void CrossPointSettings::setReaderStatusBar(const ReaderStatusBarPosition position, + const ReaderStatusBarConfig& config) { + std::lock_guard lock(_mutex); + if (position == ReaderStatusBarPosition::Top) { + topReaderStatusBar = config; + legacyXtcTopUsesBottom = 0; + } else { + bottomReaderStatusBar = config; + } } ReaderRenderSpec CrossPointSettings::readerRenderSpec(const uint16_t viewportWidth, const uint16_t viewportHeight, diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d78ff176c98..580d27f888a 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -10,6 +10,7 @@ #include #include "ReaderFontSizeStep.h" +#include "util/ReaderStatusBarConfig.h" class CrossPointSettings : public PersistableStore { private: @@ -85,6 +86,7 @@ class CrossPointSettings : public PersistableStore { XTC_STATUS_BAR_HIDE = 0, XTC_STATUS_BAR_BOTTOM = 1, XTC_STATUS_BAR_TOP = 2, + XTC_STATUS_BAR_BOTH = 3, XTC_STATUS_BAR_MODE_COUNT }; enum HIDE_CLOCK_MODE { HIDE_CLOCK_NEVER = 0, HIDE_CLOCK_IN_READER = 1, HIDE_CLOCK_ALWAYS = 2, HIDE_CLOCK_MODE_COUNT }; @@ -264,6 +266,7 @@ class CrossPointSettings : public PersistableStore { // raw values or they can silently change an existing binding's behavior. PREVIOUS_PAGE = 31, NEARBY_POSITION_SYNC = 32, + LIBRARY = 33, SHORT_PWRBTN_COUNT }; @@ -302,6 +305,7 @@ class CrossPointSettings : public PersistableStore { CHORD_TOGGLE_TOUCHSCREEN = 28, CHORD_PREVIOUS_PAGE = 29, CHORD_NEARBY_POSITION_SYNC = 30, + CHORD_LIBRARY = 31, POWER_CHORD_ACTION_COUNT }; @@ -396,6 +400,7 @@ class CrossPointSettings : public PersistableStore { // Appended: values are persisted in settings.bin. LONG_MENU_QUICK_ACTIONS = 22, LONG_MENU_QUICK_LOCK = 23, + LONG_MENU_LIBRARY = 24, LONG_PRESS_MENU_ACTION_COUNT }; @@ -438,6 +443,20 @@ class CrossPointSettings : public PersistableStore { uint8_t statusBarTimeLeft = TIME_LEFT_HIDE; uint8_t statusBarBattery = 1; uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE; + ReaderStatusBarConfig topReaderStatusBar{}; + ReaderStatusBarConfig bottomReaderStatusBar = [] { + ReaderStatusBarConfig config; + config.slots = {ReaderStatusBarItem::Battery, + ReaderStatusBarItem::Empty, + ReaderStatusBarItem::Empty, + ReaderStatusBarItem::TitleChapter, + ReaderStatusBarItem::ChapterPageCount, + ReaderStatusBarItem::BookProgressPercentage, + ReaderStatusBarItem::Empty}; + return config; + }(); + uint8_t legacyXtcTopUsesBottom = 0; + uint8_t showClockOutsideReader = 0; // Clock visibility mode (requires an RTC-backed clock). uint8_t hideClock = HIDE_CLOCK_ALWAYS; // Clock UTC offset in quarter-hour steps, biased by 48 so it fits in uint8_t. @@ -692,10 +711,11 @@ class CrossPointSettings : public PersistableStore { : POWER_BUTTON_WAKE_LONG_MS; } - bool shouldShowClockInReader() const { return hideClock == HIDE_CLOCK_NEVER; } - bool shouldShowClockOutsideReader() const { - return hideClock == HIDE_CLOCK_NEVER || hideClock == HIDE_CLOCK_IN_READER; + bool shouldShowClockInReader() const { + return topReaderStatusBar.contains(ReaderStatusBarItem::Clock) || + bottomReaderStatusBar.contains(ReaderStatusBarItem::Clock); } + bool shouldShowClockOutsideReader() const { return showClockOutsideReader != 0; } bool shouldTrackReadingStats() const { #ifdef CROSSINK_ENABLE_READING_STATS_TOGGLE return trackReadingStats != 0; @@ -735,28 +755,9 @@ class CrossPointSettings : public PersistableStore { void toJson(JsonDocument& doc) const; bool fromJson(JsonVariantConst doc, bool importingCrossPoint = false); - struct StatusBarSpec { - bool showChapterPageCount = false; - bool showBookProgressPercent = false; - bool showStablePageNumbers = false; - uint8_t titleMode = HIDE_TITLE; - uint8_t timeLeftMode = TIME_LEFT_HIDE; - bool showBattery = false; - bool showBatteryPercent = false; - bool showClock = false; - uint8_t progressBarMode = HIDE_PROGRESS; - uint8_t progressBarHeightPx = 0; - uint8_t xtcMode = XTC_STATUS_BAR_HIDE; - - bool textLaneVisible(bool clockAvailable) const { - return showChapterPageCount || showBookProgressPercent || showStablePageNumbers || titleMode != HIDE_TITLE || - timeLeftMode != TIME_LEFT_HIDE || showBattery || (showClock && clockAvailable); - } - bool showsProgressBar() const { return progressBarMode != HIDE_PROGRESS; } - bool showsTitle() const { return titleMode != HIDE_TITLE; } - }; - - StatusBarSpec statusBarSpec() const; + static bool parseReaderStatusBars(JsonVariantConst json, ReaderStatusBarsPayload& config); + ReaderStatusBarConfig readerStatusBar(ReaderStatusBarPosition position) const; + void setReaderStatusBar(ReaderStatusBarPosition position, const ReaderStatusBarConfig& config); ReaderRenderSpec readerRenderSpec(uint16_t viewportWidth, uint16_t viewportHeight, EpubRenderMode renderMode = EpubRenderMode::CrossInkDefault) const; diff --git a/src/GlobalActions.h b/src/GlobalActions.h index dd26d461aef..175c6ab59f9 100644 --- a/src/GlobalActions.h +++ b/src/GlobalActions.h @@ -32,6 +32,7 @@ inline bool isPowerButtonActionAvailableOutsideReader(const CrossPointSettings:: case CrossPointSettings::SHORT_PWRBTN::CREATE_HOTSPOT: case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FRONTLIGHT: case CrossPointSettings::SHORT_PWRBTN::TOGGLE_TOUCHSCREEN: + case CrossPointSettings::SHORT_PWRBTN::LIBRARY: return true; case CrossPointSettings::SHORT_PWRBTN::IGNORE: case CrossPointSettings::SHORT_PWRBTN::PAGE_TURN: diff --git a/src/QuickActions.h b/src/QuickActions.h index 3b9fe7f694d..6d1a1699381 100644 --- a/src/QuickActions.h +++ b/src/QuickActions.h @@ -57,7 +57,7 @@ inline constexpr std::array shortcutActionOrder = { +inline constexpr std::array shortcutActionOrder = { CrossPointSettings::IGNORE, CrossPointSettings::SLEEP, CrossPointSettings::PAGE_TURN, @@ -73,6 +73,7 @@ inline constexpr std::array shortcutAction CrossPointSettings::TOGGLE_TILT_PAGE_TURN, CrossPointSettings::SYNC_PROGRESS, CrossPointSettings::NEARBY_POSITION_SYNC, + CrossPointSettings::LIBRARY, CrossPointSettings::FILE_TRANSFER, CrossPointSettings::CALIBRE_WIRELESS, CrossPointSettings::JOIN_NETWORK, @@ -93,7 +94,9 @@ inline constexpr std::array shortcutAction inline bool supportsTiltPageTurn() { return halTiltSensor.isAvailable(); } inline bool isActionAvailable(const uint8_t action) { - if (action == CrossPointSettings::PREVIOUS_PAGE || action == CrossPointSettings::NEARBY_POSITION_SYNC) return true; + if (action == CrossPointSettings::PREVIOUS_PAGE || action == CrossPointSettings::NEARBY_POSITION_SYNC || + action == CrossPointSettings::LIBRARY) + return true; if (action == CrossPointSettings::QUICK_ACTIONS || action == CrossPointSettings::QUICK_LOCK) return true; if (action == CrossPointSettings::TOGGLE_FRONTLIGHT) return Frontlight.present(); if (action == CrossPointSettings::TOGGLE_TOUCHSCREEN) return gpio.hasTouch(); @@ -120,6 +123,7 @@ inline StrId actionLabel(const uint8_t action) { if (action == CrossPointSettings::QUICK_LOCK) return StrId::STR_QUICK_LOCK; if (action == CrossPointSettings::PREVIOUS_PAGE) return StrId::STR_PREV_PAGE; if (action == CrossPointSettings::NEARBY_POSITION_SYNC) return StrId::STR_NEARBY_POSITION_SYNC; + if (action == CrossPointSettings::LIBRARY) return StrId::STR_LIBRARY; return StrId::STR_HOME_BUTTON_LOCK; } diff --git a/src/SettingsList.h b/src/SettingsList.h index d3c1ea77a7f..1e4fccbc7b2 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -370,6 +370,8 @@ inline uint8_t shortcutRawValue(const ShortcutOptionCatalog catalog, const Cross return Chord::CHORD_SYNC_PROGRESS; case Action::NEARBY_POSITION_SYNC: return Chord::CHORD_NEARBY_POSITION_SYNC; + case Action::LIBRARY: + return Chord::CHORD_LIBRARY; case Action::FILE_TRANSFER: return Chord::CHORD_FILE_TRANSFER; case Action::CALIBRE_WIRELESS: @@ -456,6 +458,8 @@ inline uint8_t shortcutRawValue(const ShortcutOptionCatalog catalog, const Cross return LongPress::LONG_MENU_QUICK_ACTIONS; case Action::QUICK_LOCK: return LongPress::LONG_MENU_QUICK_LOCK; + case Action::LIBRARY: + return LongPress::LONG_MENU_LIBRARY; case Action::PAGE_TURN: case Action::PREVIOUS_PAGE: case Action::NEARBY_POSITION_SYNC: @@ -555,6 +559,8 @@ inline const std::vector& getBaseSettingsList() { StrId::STR_CAT_DISPLAY) .withEnumRawValues({CrossPointSettings::HIDE_CLOCK_NEVER, CrossPointSettings::HIDE_CLOCK_IN_READER, CrossPointSettings::HIDE_CLOCK_ALWAYS})); + add(SettingInfo::Toggle(StrId::STR_CLOCK_OUTSIDE_READER, &CrossPointSettings::showClockOutsideReader, + "showClockOutsideReader", StrId::STR_CAT_DISPLAY)); add(SettingInfo::Enum(StrId::STR_REFRESH_FREQ, &CrossPointSettings::refreshFrequency, {StrId::STR_PAGES_1, StrId::STR_PAGES_5, StrId::STR_PAGES_10, StrId::STR_PAGES_15, StrId::STR_PAGES_30, StrId::STR_NEVER}, @@ -857,38 +863,39 @@ inline const std::vector& getBaseSettingsList() { }, "koSyncBehavior", StrId::STR_KOREADER_SYNC)); - // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- + // Legacy fields stay in JSON for one-time status bar migration; the web + // editor uses /api/status-bars instead of exposing these controls. add(SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount, - "statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR)); + "statusBarChapterPageCount", StrId::STR_STATUS_BARS)); add(SettingInfo::Toggle(StrId::STR_STABLE_PAGE_NUMBERS, &CrossPointSettings::stablePageNumbers, "stablePageNumbers", - StrId::STR_CUSTOMISE_STATUS_BAR)); + StrId::STR_STATUS_BARS)); add(SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage, - "statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR)); + "statusBarBookProgressPercentage", StrId::STR_STATUS_BARS)); add(SettingInfo::Enum(StrId::STR_PERCENTAGE_FORMAT, &CrossPointSettings::statusBarBookPercentageFormat, {StrId::STR_PERCENTAGE_FORMAT_WHOLE, StrId::STR_PERCENTAGE_FORMAT_ONE_DECIMAL, StrId::STR_PERCENTAGE_FORMAT_TWO_DECIMALS}, - "statusBarBookPercentageFormat", StrId::STR_CUSTOMISE_STATUS_BAR)); + "statusBarBookPercentageFormat", StrId::STR_STATUS_BARS)); add(SettingInfo::Enum(StrId::STR_PROGRESS_BAR, &CrossPointSettings::statusBarProgressBar, {StrId::STR_HIDE, StrId::STR_BOOK, StrId::STR_CHAPTER}, "statusBarProgressBar", - StrId::STR_CUSTOMISE_STATUS_BAR) + StrId::STR_STATUS_BARS) .withEnumRawValues({CrossPointSettings::HIDE_PROGRESS, CrossPointSettings::BOOK_PROGRESS, CrossPointSettings::CHAPTER_PROGRESS})); add(SettingInfo::Enum(StrId::STR_PROGRESS_BAR_THICKNESS, &CrossPointSettings::statusBarProgressBarThickness, {StrId::STR_PROGRESS_BAR_THIN, StrId::STR_PROGRESS_BAR_MEDIUM, StrId::STR_PROGRESS_BAR_THICK}, - "statusBarProgressBarThickness", StrId::STR_CUSTOMISE_STATUS_BAR)); + "statusBarProgressBarThickness", StrId::STR_STATUS_BARS)); add(SettingInfo::Enum(StrId::STR_TITLE, &CrossPointSettings::statusBarTitle, {StrId::STR_HIDE, StrId::STR_BOOK, StrId::STR_CHAPTER}, "statusBarTitle", - StrId::STR_CUSTOMISE_STATUS_BAR) + StrId::STR_STATUS_BARS) .withEnumRawValues( {CrossPointSettings::HIDE_TITLE, CrossPointSettings::BOOK_TITLE, CrossPointSettings::CHAPTER_TITLE})); add(SettingInfo::Enum(StrId::STR_TIME_LEFT, &CrossPointSettings::statusBarTimeLeft, {StrId::STR_HIDE, StrId::STR_CHAPTER, StrId::STR_BOOK}, "statusBarTimeLeft", - StrId::STR_CUSTOMISE_STATUS_BAR)); + StrId::STR_STATUS_BARS)); add(SettingInfo::Toggle(StrId::STR_BATTERY, &CrossPointSettings::statusBarBattery, "statusBarBattery", - StrId::STR_CUSTOMISE_STATUS_BAR)); + StrId::STR_STATUS_BARS)); add(SettingInfo::Enum(StrId::STR_XTC_STATUS_BAR, &CrossPointSettings::xtcStatusBarMode, - {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}, "xtcStatusBarMode", - StrId::STR_CUSTOMISE_STATUS_BAR)); + {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP, StrId::STR_STATUS_BAR_BOTH}, + "xtcStatusBarMode", StrId::STR_STATUS_BARS)); // Clock detail entries live under System > Device in the device UI. // Range 0..104 = quarter-hour steps from UTC-12:00 to UTC+14:00, biased by 48. add(SettingInfo::Value(StrId::STR_CLOCK_UTC_OFFSET, &CrossPointSettings::clockUtcOffsetQ, {0, 104, 1}, @@ -1109,7 +1116,7 @@ inline std::vector buildReaderSettingsParentList(const std::vector< readerSettings.reserve(12); readerSettings.push_back(SettingInfo::Submenu(StrId::STR_READER_FONT_OPTIONS, SettingAction::ReaderFontOptions)); readerSettings.push_back(SettingInfo::Submenu(StrId::STR_READER_PAGE_LAYOUT, SettingAction::ReaderPageLayout)); - readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar)); + readerSettings.push_back(SettingInfo::Action(StrId::STR_STATUS_BARS, SettingAction::CustomiseStatusBar)); addSettingByName(readerSettings, allSettings, StrId::STR_PUBLISHER_PAGE_NUMBERS); addSettingByName(readerSettings, allSettings, StrId::STR_DISABLE_TOUCHSCREEN); addSettingByName(readerSettings, allSettings, StrId::STR_EMBEDDED_STYLE); @@ -1324,7 +1331,7 @@ inline std::vector buildGroupedDisplaySettingsList(const std::vecto } addDisplaySetting(StrId::STR_HIDE_BATTERY); if (halClock.isAvailable()) { - addDisplaySetting(StrId::STR_HIDE_CLOCK); + addDisplaySetting(StrId::STR_CLOCK_OUTSIDE_READER); } addDisplaySetting(StrId::STR_REFRESH_FREQ); addDisplaySetting(StrId::STR_NIGHT_MODE); diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index a3b5ef52dc4..1367c57ecfb 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -88,9 +88,9 @@ FrontlightPanelContext buildFrontlightPanelContext(Activity& activity, GfxRender context.bookTitle = activity.getCurrentBookTitle(); context.bookPath = currentPath; context.activeEpub = activity.isEpubReaderActivity() && currentEpubValid; - if (shouldShowStickyReaderDetails(hasStickyReaderDetailsPanel(), Frontlight.present(), context.activeReaderBook) && - activity.getFrontlightPanelBookDetails(context.bookDetails)) { - context.showReaderDetails = true; + if (activity.getFrontlightPanelBookDetails(context.bookDetails)) { + context.showReaderDetails = + shouldShowStickyReaderDetails(hasStickyReaderDetailsPanel(), Frontlight.present(), context.activeReaderBook); context.bookTitle = context.bookDetails.title; } context.readingStatsActivity = activity.createFrontlightReadingStatsActivity(); @@ -1328,15 +1328,12 @@ RequestUpdateResult ActivityManager::requestUpdateAndWait() { // RenderLock -RenderLock::RenderLock() { - xSemaphoreTake(activityManager.renderingMutex, portMAX_DELAY); - isLocked = true; +RenderLock::RenderLock(const Mode mode) { + isLocked = xSemaphoreTake(activityManager.renderingMutex, mode == Mode::Try ? 0 : portMAX_DELAY) == pdTRUE; + assert(mode == Mode::Try || isLocked); } -RenderLock::RenderLock([[maybe_unused]] Activity&) { - xSemaphoreTake(activityManager.renderingMutex, portMAX_DELAY); - isLocked = true; -} +RenderLock::RenderLock([[maybe_unused]] Activity&, const Mode mode) : RenderLock(mode) {} RenderLock::~RenderLock() { if (isLocked) { diff --git a/src/activities/RenderLock.h b/src/activities/RenderLock.h index 3b28e568c10..55f2227ae9b 100644 --- a/src/activities/RenderLock.h +++ b/src/activities/RenderLock.h @@ -7,11 +7,13 @@ class RenderLock { bool isLocked = false; public: - explicit RenderLock(); - explicit RenderLock(Activity&); // Compatibility overload used by activity call sites. + enum class Mode { Blocking, Try }; + explicit RenderLock(Mode mode = Mode::Blocking); + explicit RenderLock(Activity&, Mode mode = Mode::Blocking); // Compatibility overload used by activity call sites. RenderLock(const RenderLock&) = delete; RenderLock& operator=(const RenderLock&) = delete; ~RenderLock(); void unlock(); + bool ownsLock() const { return isLocked; } static bool peek(); }; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index ec6f598b418..f523b627bea 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -115,7 +115,7 @@ void mirrorBitmapEdges(const GfxRenderer& renderer, int drawX, int drawY, int dr } void hideOverlayBatteryStrip(const GfxRenderer& renderer) { - if (!SETTINGS.statusBarBattery) { + if (!SETTINGS.readerStatusBar(ReaderStatusBarPosition::Bottom).contains(ReaderStatusBarItem::Battery)) { return; } @@ -135,7 +135,7 @@ void hideOverlayBatteryStrip(const GfxRenderer& renderer) { // Reserve the full left-side status indicator lane used by bookmark + battery. // This keeps chapter/progress text readable while removing the battery glance target. - static constexpr int bookmarkReserveWidth = 13; // bookmark width + gap from BaseTheme::drawStatusBar() + static constexpr int bookmarkReserveWidth = 13; // bookmark width + gap from BaseTheme::drawReaderStatusBar() static constexpr int batteryPercentSpacing = 4; // matches BaseTheme::batteryPercentSpacing const int clearWidth = bookmarkReserveWidth + metrics.batteryWidth + diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index e28d3698bf4..da2ff6581ee 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -178,6 +179,7 @@ void OpdsBookBrowserActivity::onEnter() { } void OpdsBookBrowserActivity::onExit() { + library::invalidateLibraryIndex(); Activity::onExit(); // Joins the background download before Wi-Fi goes down and before the // cache it would hand its page to is freed. diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index dd3fc445166..92a47ed1058 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -433,6 +434,7 @@ void FileBrowserActivity::promptDeleteFile(const std::string& fullPath, const st return; } + library::invalidateLibraryIndex(); BookActions::clearFileMetadata(fullPath); if (!Storage.remove(fullPath.c_str())) { LOG_ERR("FileBrowser", "Failed to delete file: %s", fullPath.c_str()); @@ -478,6 +480,7 @@ void FileBrowserActivity::promptDeleteDirectory(const std::string& fullPath, con std::vector metadataPaths; collectMetadataPathsRecursively(dirPath, metadataPaths); + library::invalidateLibraryIndex(); if (!Storage.removeDir(dirPath.c_str())) { LOG_ERR("FileBrowser", "Failed to delete directory: %s", dirPath.c_str()); return; @@ -935,6 +938,7 @@ void FileBrowserActivity::renameFile(const std::string& oldPath, const std::stri LOG_ERR("FileBrowser", "Failed to save renamed favorite image path"); } + library::invalidateLibraryIndex(); ImageFolderIndex::invalidateForPath(oldPath.c_str()); ImageFolderIndex::invalidateForPath(newPath.c_str()); sdFontSystem.markRegistryDirtyForPath(oldPath.c_str()); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 44c25197340..2d79e834616 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -159,7 +159,7 @@ void appendHashedFileStateToKey(std::string& key, const std::string& path) { std::string getRecentBookCachePath(const RecentBook& book) { if (FsHelpers::hasEpubExtension(book.path)) { - return Epub::cachePathForFilePath(book.path, "/.crosspoint"); + return Epub::resolveCachePathForFilePath(book.path, "/.crosspoint"); } if (FsHelpers::hasXtcExtension(book.path)) { return "/.crosspoint/xtc_" + std::to_string(std::hash{}(book.path)); @@ -179,42 +179,27 @@ BookReadingStats loadRecentBookStats(const RecentBook& book) { return BookReadingStats::load(cachePath); } -bool loadEpubHighlightedContext(const RecentBook& book, const bool loadProgress, const bool loadChapterTitle, - float* progressPercent, std::string* chapterTitle) { - if (!FsHelpers::hasEpubExtension(book.path) || (!loadProgress && !loadChapterTitle)) { - return false; - } - - Epub epub(book.path, "/.crosspoint"); - if (!epub.load(false, true)) { - return false; - } +float loadRecentBookProgress(const RecentBook& book) { + return FsHelpers::hasEpubExtension(book.path) ? RecentBookProgress::loadCachedEpubPercent(book) + : RecentBookProgress::loadPercent(book); +} +std::string loadEpubHighlightedChapterTitle(const RecentBook& book) { + const std::string cachePath = getRecentBookCachePath(book); EpubReaderUtils::Progress progress; - if (!EpubReaderUtils::loadProgress(epub, progress, "HOME")) { - return false; - } + if (!EpubReaderUtils::loadProgressFromCachePath("HOME", cachePath, progress)) return {}; - if (loadProgress && progressPercent) { - if (progress.hasPageCount && progress.pageCount > 0) { - const float chapterProgress = - static_cast(progress.pageNumber + 1) / static_cast(progress.pageCount); - *progressPercent = - std::clamp(epub.calculateProgress(progress.spineIndex, chapterProgress) * 100.0f, 0.0f, 100.0f); - } else { - *progressPercent = -1.0f; - } - } - - if (loadChapterTitle && chapterTitle) { - chapterTitle->clear(); - const auto spineItem = epub.getSpineItem(progress.spineIndex); - if (spineItem.tocIndex >= 0) { - *chapterTitle = epub.getTocItem(spineItem.tocIndex).title; - } + // This metadata owner contains several strings and file handles. Keep it off + // the small activity stack, and never parse/index a book just to paint Home. + auto metadata = makeUniqueNoThrow(cachePath); + if (!metadata) { + LOG_ERR("HOME", "Cannot allocate chapter metadata"); + return {}; } - - return true; + if (!metadata->load() || progress.spineIndex >= metadata->getSpineCount()) return {}; + const int tocIndex = metadata->getSpineEntry(progress.spineIndex).tocIndex; + if (tocIndex < 0 || tocIndex >= metadata->getTocCount()) return {}; + return metadata->getTocEntry(tocIndex).title; } void updateRecentBookCover(const RecentBook& book) { @@ -651,7 +636,7 @@ void HomeActivity::loadAllBookStats() { const int count = std::min(static_cast(recentBooks.size()), kMaxCachedBooks); for (int i = 0; i < count; ++i) { cachedBookStats[i] = loadRecentBookStats(recentBooks[i]); - cachedBookProgress[i] = RecentBookProgress::loadPercent(recentBooks[i]); + cachedBookProgress[i] = loadRecentBookProgress(recentBooks[i]); } bookStatsCached = true; LOG_DBG("HOME", "carousel: cached stats/progress for %d book(s) in %lums", count, millis() - start); @@ -1050,7 +1035,7 @@ bool HomeActivity::handleFrontlightPanelResult(const FrontlightPanelResult& resu return true; } -void HomeActivity::updateHighlightedBookContext(const bool allowEpubLoad) { +void HomeActivity::updateHighlightedBookContext(const bool allowChapterTitleRead) { currentBookStats = BookReadingStats{}; currentBookProgressPercent = -1.0f; currentBookChapterTitle.clear(); @@ -1064,20 +1049,14 @@ void HomeActivity::updateHighlightedBookContext(const bool allowEpubLoad) { if (useCachedStats) { currentBookStats = cachedBookStats[idx]; currentBookProgressPercent = cachedBookProgress[idx]; - if (allowEpubLoad && loadChapterTitle && isEpub) { - loadEpubHighlightedContext(book, false, true, nullptr, ¤tBookChapterTitle); + if (allowChapterTitleRead && loadChapterTitle && isEpub) { + currentBookChapterTitle = loadEpubHighlightedChapterTitle(book); } } else { currentBookStats = loadRecentBookStats(book); - if (isEpub && allowEpubLoad) { - loadEpubHighlightedContext(book, true, loadChapterTitle, ¤tBookProgressPercent, ¤tBookChapterTitle); - } else if (!isEpub) { - currentBookProgressPercent = RecentBookProgress::loadPercent(book); - } else { - currentBookProgressPercent = RecentBookProgress::loadCachedEpubPercent(book); - } - if (loadChapterTitle && !isEpub) { - currentBookChapterTitle.clear(); + currentBookProgressPercent = loadRecentBookProgress(book); + if (isEpub && allowChapterTitleRead && loadChapterTitle) { + currentBookChapterTitle = loadEpubHighlightedChapterTitle(book); } } } @@ -1235,7 +1214,7 @@ void HomeActivity::renderCarouselFrameToCurrentBuffer(int bookIdx, BookReadingSt frameProgressPercent = cachedBookProgress[bookIdx]; } else { frameStats = loadRecentBookStats(recentBooks[bookIdx]); - frameProgressPercent = RecentBookProgress::loadPercent(recentBooks[bookIdx]); + frameProgressPercent = loadRecentBookProgress(recentBooks[bookIdx]); } if (hasAnyBookStats(frameStats)) frameStatsPtr = &frameStats; } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 8572236c8a3..cd9665aafbc 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -124,7 +124,7 @@ class HomeActivity final : public Activity { int getVisibleRecentBookCount() const; bool canSwapHomeBook() const; void showNextRecentBookOnHome(); - void updateHighlightedBookContext(bool allowEpubLoad = true); + void updateHighlightedBookContext(bool allowChapterTitleRead = true); void loadRecentBooks(int maxBooks); void loadAllBookStats(); void loadRecentCovers(int coverHeight); diff --git a/src/activities/home/RecentBookProgress.cpp b/src/activities/home/RecentBookProgress.cpp index 3e5757b2829..c04d902648a 100644 --- a/src/activities/home/RecentBookProgress.cpp +++ b/src/activities/home/RecentBookProgress.cpp @@ -245,7 +245,7 @@ float RecentBookProgress::loadCachedEpubPercent(const RecentBook& book) { if (!FsHelpers::hasEpubExtension(book.path)) { return -1.0f; } - const std::string cachePath = Epub::cachePathForFilePath(book.path, "/.crosspoint"); + const std::string cachePath = Epub::resolveCachePathForFilePath(book.path, "/.crosspoint"); const float cachedProgress = loadCachedEpubPercentFromCachePath(cachePath); if (cachedProgress >= 0.0f) { return cachedProgress; diff --git a/src/activities/library/LibraryActivity.cpp b/src/activities/library/LibraryActivity.cpp index 24192e4190f..4167e584685 100644 --- a/src/activities/library/LibraryActivity.cpp +++ b/src/activities/library/LibraryActivity.cpp @@ -1,6 +1,7 @@ #include "LibraryActivity.h" #include +#include #include #include #include @@ -44,6 +45,7 @@ constexpr int HEADER_CONTROL_GAP = 10; // A released contact that travelled further than this is a drag, not a tap on // the row it started on. Matches the SDK's stationary tap slop. constexpr int DRAG_TAP_SLOP_PX = 28; +constexpr int FOOTER_HEIGHT = 28; int headerControlRightInset() { const auto& metrics = UITheme::getInstance().getMetrics(); @@ -359,7 +361,7 @@ void LibraryActivity::applyFilter() { } void LibraryActivity::resetViewport() { - selection = rowCount() ? CONTROL_COUNT : 3; + selection = rowCount() || !mappedInput.hasTouchHardware() ? CONTROL_COUNT : 3; showSelection = !mappedInput.hasTouchHardware(); topIndex = 0; listNav.reset(selection - CONTROL_COUNT); @@ -402,7 +404,7 @@ void LibraryActivity::openBook(const int row) { onSelectBook(book.path); } -void LibraryActivity::openSortPicker() { +void LibraryActivity::openSortPicker(const int selectedIndex) { static constexpr StrId choices[] = {StrId::STR_LIBRARY_DATE_ADDED, StrId::STR_LIBRARY_TITLE, StrId::STR_LIBRARY_AUTHOR_LAST_NAME, @@ -410,19 +412,46 @@ void LibraryActivity::openSortPicker() { StrId::STR_LIBRARY_RECENTLY_OPENED, StrId::STR_LIBRARY_SERIES, StrId::STR_LIBRARY_GENRE}; - sortPopup.setDismissOnOutsideTouchDown(true); - sortPopup.show(StrId::STR_LIBRARY_SORT_BY, choices, 7, static_cast(sort), [this](const int selected) { - if (selected < 0 || selected > static_cast(Sort::Genre)) return; - sort = static_cast(selected); - descending = sort == Sort::DateAdded || sort == Sort::RecentlyRead; + const bool buttonOnly = !mappedInput.hasTouchHardware(); + auto onSelect = [this, buttonOnly](const int selected) { + if (buttonOnly && selected == 0) { + descending = !descending; + } else { + const int method = selected - (buttonOnly ? 1 : 0); + if (method < 0 || method > static_cast(Sort::Genre)) return; + sort = static_cast(method); + if (!buttonOnly) descending = sort == Sort::DateAdded || sort == Sort::RecentlyRead; + } SETTINGS.librarySortMethod = static_cast(sort); SETTINGS.librarySortDescending = descending; if (!SETTINGS.saveToFile()) LOG_ERR("LIB", "Cannot save Library sort"); applyFilter(); resetViewport(); - }); + if (buttonOnly && selected == 0) openSortPicker(0); + }; + actionPopup.setDismissOnOutsideTouchDown(true); + if (buttonOnly) { + const char* options[] = {descending ? "Z-A" : "A-Z", I18N.get(choices[0]), I18N.get(choices[1]), + I18N.get(choices[2]), I18N.get(choices[3]), I18N.get(choices[4]), + I18N.get(choices[5]), I18N.get(choices[6])}; + actionPopup.show(tr(STR_LIBRARY_SORT_BY), options, 8, selectedIndex < 0 ? 0 : selectedIndex, std::move(onSelect)); + actionPopup.setDividerAfterOption(0); + } else { + actionPopup.show(StrId::STR_LIBRARY_SORT_BY, choices, 7, static_cast(sort), std::move(onSelect)); + } if (index.isOpen() && index.header().formatVersion < 4) - sortPopup.setDisabledOptions({false, false, false, false, false, true, true}); + actionPopup.setDisabledOptions(buttonOnly ? std::vector{false, false, false, false, false, false, true, true} + : std::vector{false, false, false, false, false, true, true}); + requestUpdate(); +} + +void LibraryActivity::openMenu() { + static constexpr StrId choices[] = {StrId::STR_SEARCH, StrId::STR_SETTINGS_SHORT, StrId::STR_LIBRARY_RESCAN}; + actionPopup.show(StrId::STR_MENU, choices, 3, 0, [this](const int selected) { + if (selected == 0) openSearch(); + if (selected == 1) openSettings(); + if (selected == 2) refreshLibrary(); + }); requestUpdate(); } @@ -541,17 +570,28 @@ void LibraryActivity::latchInput() { const auto step = [](int8_t& value, const int delta) { value = static_cast(std::max(INT8_MIN, std::min(INT8_MAX, value + delta))); }; - buttonNavigator.onNextRelease([&] { step(pending.steps, 1); }); - buttonNavigator.onPreviousRelease([&] { step(pending.steps, -1); }); - buttonNavigator.onNextContinuous([&] { step(pending.pageSteps, 1); }); - buttonNavigator.onPreviousContinuous([&] { step(pending.pageSteps, -1); }); + if (mappedInput.hasTouchHardware()) { + buttonNavigator.onNextRelease([&] { step(pending.steps, 1); }); + buttonNavigator.onPreviousRelease([&] { step(pending.steps, -1); }); + buttonNavigator.onNextContinuous([&] { step(pending.pageSteps, 1); }); + buttonNavigator.onPreviousContinuous([&] { step(pending.pageSteps, -1); }); + return; + } + // Button-only: Up/Down move through books; Left/Right open sort and menu. + using Button = MappedInputManager::Button; + buttonNavigator.onRelease({Button::Down, Button::Down}, [&] { step(pending.steps, 1); }); + buttonNavigator.onRelease({Button::Up, Button::Up}, [&] { step(pending.steps, -1); }); + buttonNavigator.onContinuous({Button::Down, Button::Down}, [&] { step(pending.pageSteps, 1); }); + buttonNavigator.onContinuous({Button::Up, Button::Up}, [&] { step(pending.pageSteps, -1); }); + if (mappedInput.wasReleased(Button::Left)) pending.leftReleased = true; + if (mappedInput.wasReleased(Button::Right)) pending.rightReleased = true; } void LibraryActivity::loop() { - if (sortPopup.isActive()) { + if (actionPopup.isActive()) { pending = {}; RenderLock lock; - sortPopup.handleInput(mappedInput, [this] { requestUpdate(); }); + actionPopup.handleInput(mappedInput, [this] { requestUpdate(); }); return; } latchInput(); @@ -605,7 +645,7 @@ void LibraryActivity::loop() { if (input.confirmReleased) { if (selection < CONTROL_COUNT) activateControl(selection); - else + else if (rowCount() > 0) openBook(selection - CONTROL_COUNT); return; } @@ -628,6 +668,14 @@ void LibraryActivity::loop() { requestUpdate(); return; } + if (input.leftReleased) { + openSortPicker(); + return; + } + if (input.rightReleased) { + openMenu(); + return; + } const int count = rowCount() + CONTROL_COUNT; const auto move = [this](const int next) { if (!showSelection) { @@ -644,14 +692,31 @@ void LibraryActivity::loop() { } requestUpdate(); }; - for (int i = 0; i < std::abs(input.steps); ++i) { - move(input.steps > 0 ? ButtonNavigator::nextIndex(selection, count) - : ButtonNavigator::previousIndex(selection, count)); - } - for (int i = 0; i < std::abs(input.pageSteps); ++i) { - const int page = listNav.pageRowsFor(rowCount()); - move(input.pageSteps > 0 ? ButtonNavigator::nextPageIndex(selection, count, page) - : ButtonNavigator::previousPageIndex(selection, count, page)); + if (mappedInput.hasTouchHardware()) { + for (int i = 0; i < std::abs(input.steps); ++i) { + move(input.steps > 0 ? ButtonNavigator::nextIndex(selection, count) + : ButtonNavigator::previousIndex(selection, count)); + } + for (int i = 0; i < std::abs(input.pageSteps); ++i) { + const int page = listNav.pageRowsFor(rowCount()); + move(input.pageSteps > 0 ? ButtonNavigator::nextPageIndex(selection, count, page) + : ButtonNavigator::previousPageIndex(selection, count, page)); + } + } else if (rowCount() > 0) { + // Button-only devices keep the selection on books; Left and Right open the + // sort picker and the menu instead of reaching the header controls. + const int bookCount = rowCount(); + for (int i = 0; i < std::abs(input.steps); ++i) { + const int row = selection - CONTROL_COUNT; + move(CONTROL_COUNT + (input.steps > 0 ? ButtonNavigator::nextIndex(row, bookCount) + : ButtonNavigator::previousIndex(row, bookCount))); + } + for (int i = 0; i < std::abs(input.pageSteps); ++i) { + const int row = selection - CONTROL_COUNT; + const int page = listNav.pageRowsFor(bookCount); + move(CONTROL_COUNT + (input.pageSteps > 0 ? ButtonNavigator::nextPageIndex(row, bookCount, page) + : ButtonNavigator::previousPageIndex(row, bookCount, page))); + } } } @@ -722,9 +787,14 @@ void LibraryActivity::fillRow(const int row, CachedRow& out) { if (date == 0) { groupHeading = "?"; } else { - char heading[11]; - std::snprintf(heading, sizeof(heading), "%04u-%02u-%02u", 1980u + (date >> 9), (date >> 5) & 15u, date & 31u); - groupHeading = heading; + char heading[20]; + const char separator = SETTINGS.dateSeparator == CrossPointSettings::DATE_SEPARATOR_PERIOD ? '.' + : SETTINGS.dateSeparator == CrossPointSettings::DATE_SEPARATOR_HYPHEN ? '-' + : '/'; + groupHeading = formatDateParts(heading, sizeof(heading), 1980u + (date >> 9), (date >> 5) & 15u, date & 31u, + SETTINGS.dateFormat, separator) + ? heading + : "?"; } out.hasHeading = true; } @@ -813,17 +883,21 @@ void LibraryActivity::buildSortHeader(UiApp::ScreenType& screen) { button.text = screen.theme().bodyText; button.styles = fui::plainStyles(); button.styles.selected = screen.theme().button.selected; - button.state = showSelection && selection == 3 ? fui::StateSelected : fui::StateNormal; + button.state = + mappedInput.hasTouchHardware() && showSelection && selection == 3 ? fui::StateSelected : fui::StateNormal; screen.button(button, method); button.label = nullptr; button.icon = fui::bitmapFromIcon(descending ? icon_arrow_down_wide_narrow_32 : icon_arrow_up_narrow_wide_32); - button.state = showSelection && selection == 4 ? fui::StateSelected : fui::StateNormal; + button.state = + mappedInput.hasTouchHardware() && showSelection && selection == 4 ? fui::StateSelected : fui::StateNormal; screen.button(button, direction); const int16_t split = static_cast((method.right() + direction.x) / 2); - screen.frame().hit(fui::Rect{band.x, band.y, static_cast(split - band.x), band.height}, ACTION_CONTROL, 3, - fui::InputTouch); - screen.frame().hit(fui::Rect{split, band.y, static_cast(band.right() - split), band.height}, ACTION_CONTROL, - 4, fui::InputTouch); + if (mappedInput.hasTouchHardware()) { + screen.frame().hit(fui::Rect{band.x, band.y, static_cast(split - band.x), band.height}, ACTION_CONTROL, 3, + fui::InputTouch); + screen.frame().hit(fui::Rect{split, band.y, static_cast(band.right() - split), band.height}, + ACTION_CONTROL, 4, fui::InputTouch); + } uiTarget.fill(fui::Rect{band.x, band.y, band.width, 1}, fui::Paint::solid(fui::Color::Black)); uiTarget.fill(fui::Rect{band.x, static_cast(band.bottom() - 1), band.width, 1}, fui::Paint::solid(fui::Color::Black)); @@ -835,39 +909,42 @@ void LibraryActivity::buildListScreen(UiApp::ScreenType& screen) { renderer.getOrientedViewableTRBL(&bounds[0], &bounds[1], &bounds[2], &bounds[3]); const int16_t headerBottom = static_cast(metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput)); + const int buttonHintsHeight = mappedInput.hasTouchHardware() ? 0 : metrics.buttonHintsHeight; screen.setContentMarginFromScreen(fui::Insets{headerBottom, static_cast(bounds[1]), - static_cast(metrics.buttonHintsHeight + bounds[2]), + static_cast(FOOTER_HEIGHT + buttonHintsHeight + bounds[2]), static_cast(bounds[3])}); // Every header icon owns a 44px touch box, with 10px of clearance. const int16_t controlSize = HEADER_CONTROL_SIZE; const auto header = TouchHeaderBackButton::headerRect(renderer, mappedInput); const int16_t right = static_cast(renderer.getScreenWidth() - bounds[1] - headerControlRightInset()); - fui::ButtonProps action; - action.action = ACTION_CONTROL; - action.inputMask = fui::InputTouch; - action.styles = fui::plainStyles(); - action.styles.selected = screen.theme().button.selected; - action.icon = fui::bitmapFromIcon(icon_refresh_cw_32); - action.value = 0; - action.state = showSelection && selection == 0 ? fui::StateSelected : fui::StateNormal; - screen.button(action, - fui::Rect{static_cast(right - 3 * controlSize - 2 * HEADER_CONTROL_GAP), - static_cast(header.y + header.height - controlSize), controlSize, controlSize}); - action.icon = fui::bitmapFromIcon(icon_search_32); - action.value = 1; - action.state = showSelection && selection == 1 ? fui::StateSelected : fui::StateNormal; - screen.button(action, - fui::Rect{static_cast(right - 2 * controlSize - HEADER_CONTROL_GAP), - static_cast(header.y + header.height - controlSize), controlSize, controlSize}); - action.icon = fui::bitmapFromIcon(icon_ellipsis_vertical_32); - action.value = 2; - action.state = showSelection && selection == 2 ? fui::StateSelected : fui::StateNormal; - // Give the small overflow icon a 56px touch target. Keep its bottom edge at - // the header boundary so the expanded target cannot steal taps from sorting. - if (mappedInput.hasTouchHardware()) action.hitPadding = fui::Insets{12, 6, 0, 6}; - screen.button(action, - fui::Rect{static_cast(right - controlSize), - static_cast(header.y + header.height - controlSize), controlSize, controlSize}); + if (mappedInput.hasTouchHardware()) { + fui::ButtonProps action; + action.action = ACTION_CONTROL; + action.inputMask = fui::InputTouch; + action.styles = fui::plainStyles(); + action.styles.selected = screen.theme().button.selected; + action.icon = fui::bitmapFromIcon(icon_refresh_cw_32); + action.value = 0; + action.state = showSelection && selection == 0 ? fui::StateSelected : fui::StateNormal; + screen.button(action, + fui::Rect{static_cast(right - 3 * controlSize - 2 * HEADER_CONTROL_GAP), + static_cast(header.y + header.height - controlSize), controlSize, controlSize}); + action.icon = fui::bitmapFromIcon(icon_search_32); + action.value = 1; + action.state = showSelection && selection == 1 ? fui::StateSelected : fui::StateNormal; + screen.button(action, + fui::Rect{static_cast(right - 2 * controlSize - HEADER_CONTROL_GAP), + static_cast(header.y + header.height - controlSize), controlSize, controlSize}); + action.icon = fui::bitmapFromIcon(icon_ellipsis_vertical_32); + action.value = 2; + action.state = showSelection && selection == 2 ? fui::StateSelected : fui::StateNormal; + // Give the small overflow icon a 56px touch target. Keep its bottom edge at + // the header boundary so the expanded target cannot steal taps from sorting. + action.hitPadding = fui::Insets{12, 6, 0, 6}; + screen.button(action, + fui::Rect{static_cast(right - controlSize), + static_cast(header.y + header.height - controlSize), controlSize, controlSize}); + } buildSortHeader(screen); if (scanFailed || index.ranksDegraded() || filterFailed) { const auto warning = screen.take(fui::LayoutAnchor::Top, uiTarget.lineHeight(screen.theme().smallText.font) + 8); @@ -931,11 +1008,25 @@ void LibraryActivity::render(RenderLock&&) { if (!listNav.consumeRebuildNeeded()) break; } uiReady = true; - if (sortPopup.processRender(renderer, mappedInput)) return; - const auto labels = mappedInput.mapLabels(mappedInput.withBackArrow(query.empty() ? tr(STR_HOME) : tr(STR_BACK)), - selection < CONTROL_COUNT ? tr(STR_SELECT) : tr(STR_OPEN), tr(STR_DIR_UP), - tr(STR_DIR_DOWN)); + if (actionPopup.processRender(renderer, mappedInput)) return; + const char* confirmLabel = !mappedInput.hasTouchHardware() && rowCount() == 0 ? "" + : selection < CONTROL_COUNT ? tr(STR_SELECT) + : tr(STR_OPEN); + const auto labels = + mappedInput.mapLabels(mappedInput.withBackArrow(query.empty() ? tr(STR_HOME) : tr(STR_BACK)), confirmLabel, + mappedInput.hasTouchHardware() ? tr(STR_DIR_UP) : tr(STR_SORT), + mappedInput.hasTouchHardware() ? tr(STR_DIR_DOWN) : tr(STR_MENU)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + char footer[32]; + snprintf(footer, sizeof(footer), tr(STR_LIBRARY_FILES_COUNT), static_cast(rowCount())); + int bounds[4]{}; + renderer.getOrientedViewableTRBL(&bounds[0], &bounds[1], &bounds[2], &bounds[3]); + const int buttonHintsHeight = + mappedInput.hasTouchHardware() ? 0 : UITheme::getInstance().getMetrics().buttonHintsHeight; + renderer.drawCenteredText(SMALL_FONT_ID, + renderer.getScreenHeight() - bounds[2] - buttonHintsHeight - + (FOOTER_HEIGHT + renderer.getLineHeight(SMALL_FONT_ID)) / 2, + footer); if (pendingCacheDeletedFeedback) GUI.drawPopup(renderer, tr(STR_BOOK_CACHE_DELETED)); renderer.displayBuffer(); } @@ -953,6 +1044,7 @@ void LibraryActivity::promptDeleteBook(const RecentBook& book) { return; } + library::invalidateLibraryIndex(); RECENT_BOOKS.removeByPath(path); reloadAfterBookAction(); }; diff --git a/src/activities/library/LibraryActivity.h b/src/activities/library/LibraryActivity.h index 01f596f3ff5..320cd6caebc 100644 --- a/src/activities/library/LibraryActivity.h +++ b/src/activities/library/LibraryActivity.h @@ -19,19 +19,18 @@ class LibraryActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; - bool blocksGlobalInput() const override { return sortPopup.isActive(); } + bool blocksGlobalInput() const override { return actionPopup.isActive(); } private: enum class Sort : uint8_t { DateAdded, Title, AuthorLast, AuthorFirst, RecentlyRead, Series, Genre }; - // Ring: refresh, search, settings, sort method, direction, then the book rows. All controls are - // reachable on button-only devices as well as through SDK touch routing. + // Touch header controls precede the book rows; button-only navigation visits books directly. static constexpr int CONTROL_COUNT = 5; using UiApp = freeink::ui::FreeInkApp<32, 4>; freeink::ui::GfxRendererTarget uiTarget; UiApp app; ButtonNavigator buttonNavigator; freeink::ui::ListNav listNav; - OptionPopup sortPopup; + OptionPopup actionPopup; library::LibraryIndexFile index; Sort sort = Sort::RecentlyRead; bool descending = true; @@ -89,6 +88,8 @@ class LibraryActivity final : public Activity { bool headerBack = false; bool confirmReleased = false; bool backReleased = false; + bool leftReleased = false; // button-only: sort picker + bool rightReleased = false; // button-only: menu int16_t pressX = 0; int16_t pressY = 0; int16_t releaseX = 0; @@ -132,7 +133,8 @@ class LibraryActivity final : public Activity { void resetViewport(); void reloadAfterBookAction(); void openBook(int row); - void openSortPicker(); + void openSortPicker(int selectedIndex = -1); + void openMenu(); void refreshLibrary(); void openSearch(); void openSettings(); diff --git a/src/activities/network/CalibreConnectActivity.cpp b/src/activities/network/CalibreConnectActivity.cpp index c897acf40cd..117cb7136ae 100644 --- a/src/activities/network/CalibreConnectActivity.cpp +++ b/src/activities/network/CalibreConnectActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -53,6 +54,7 @@ void CalibreConnectActivity::onEnter() { } void CalibreConnectActivity::onExit() { + library::invalidateLibraryIndex(); Activity::onExit(); MDNS.end(); diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 7aa82ec977a..2842b896473 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -99,6 +100,7 @@ void CrossPointWebServerActivity::onEnter() { } void CrossPointWebServerActivity::onExit() { + library::invalidateLibraryIndex(); Activity::onExit(); state = WebServerActivityState::SHUTTING_DOWN; diff --git a/src/activities/network/NearbyBookTransferActivity.cpp b/src/activities/network/NearbyBookTransferActivity.cpp index c83aa5b63ee..1dbdca17fdf 100644 --- a/src/activities/network/NearbyBookTransferActivity.cpp +++ b/src/activities/network/NearbyBookTransferActivity.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -135,6 +136,7 @@ void NearbyBookTransferActivity::onEnter() { } void NearbyBookTransferActivity::onExit() { + library::invalidateLibraryIndex(); sourceFile_.close(); receiveFile_.close(); stopRadio(); diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index 3dd037a0b9d..4d15e6b1b4b 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -69,10 +69,11 @@ void NetworkModeSelectionActivity::onEnter() { Activity::onEnter(); selectedIndex = 0; + buttonSelectionActive = !mappedInput.hasTouchHardware(); ui.closeRouting(); visibleRows = 1; topIndex = 0; - listNav.reset(listIndexForMenuIndex(selectedIndex)); + listNav.reset(buttonSelectionActive ? listIndexForMenuIndex(selectedIndex) : -1); ui.reset(); ui.app.on(ACTION_ROW, &NetworkModeSelectionActivity::onRowEvent, this); ui.app.setScreen(&NetworkModeSelectionActivity::listScreen, this); @@ -116,7 +117,7 @@ void NetworkModeSelectionActivity::loop() { bool moved = false; { RenderLock lock(*this); - listNav.selected = listIndexForMenuIndex(selectedIndex); + listNav.selected = buttonSelectionActive ? listIndexForMenuIndex(selectedIndex) : -1; listNav.top = topIndex; listNav.visibleRows = visibleRows; // Page by the rows the last layout measured, not the fixed-height @@ -137,6 +138,7 @@ void NetworkModeSelectionActivity::loop() { { RenderLock lock(*this); selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT); + buttonSelectionActive = true; listNav.selected = listIndexForMenuIndex(selectedIndex); listNav.top = topIndex; listNav.visibleRows = visibleRows; @@ -150,6 +152,7 @@ void NetworkModeSelectionActivity::loop() { { RenderLock lock(*this); selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEM_COUNT); + buttonSelectionActive = true; listNav.selected = listIndexForMenuIndex(selectedIndex); listNav.top = topIndex; listNav.visibleRows = visibleRows; @@ -201,7 +204,6 @@ void NetworkModeSelectionActivity::buildListScreen(UiApp::ScreenType& screen) { fui::ListProps props; props.items = items.data(); props.count = static_cast(items.size()); - props.selectedIndex = static_cast(listIndexForMenuIndex(selectedIndex)); props.action = ACTION_ROW; props.inputMask = fui::InputTouch; // physical buttons stay in loop() props.labelText = screen.theme().bodyText; @@ -217,7 +219,7 @@ void NetworkModeSelectionActivity::buildListScreen(UiApp::ScreenType& screen) { props.sectionGap = 10; const auto rows = configureUiList(props, screen.theme(), screen.body(), UiListRowType::WithSubtitle); visibleRows = rows > 0 ? rows : 1; - listNav.selected = listIndexForMenuIndex(selectedIndex); + listNav.selected = buttonSelectionActive ? listIndexForMenuIndex(selectedIndex) : -1; listNav.top = topIndex; listNav.visibleRows = visibleRows; listNav.syncToProps(screen.body(), props.rowHeight, props.rowGap, LIST_ITEM_COUNT, props); diff --git a/src/activities/network/NetworkModeSelectionActivity.h b/src/activities/network/NetworkModeSelectionActivity.h index e67a71ae21e..65fcaad7255 100644 --- a/src/activities/network/NetworkModeSelectionActivity.h +++ b/src/activities/network/NetworkModeSelectionActivity.h @@ -36,6 +36,7 @@ class NetworkModeSelectionActivity final : public Activity { ButtonNavigator buttonNavigator; int selectedIndex = 0; + bool buttonSelectionActive = false; UiHost ui; int visibleRows = 1; // rows per page at the current scale; set by the screen builder diff --git a/src/activities/network/UsbDriveActivity.cpp b/src/activities/network/UsbDriveActivity.cpp index 4f82e038834..b1093a6945e 100644 --- a/src/activities/network/UsbDriveActivity.cpp +++ b/src/activities/network/UsbDriveActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "MappedInputManager.h" @@ -50,6 +51,7 @@ void UsbDriveActivity::onEnter() { } void UsbDriveActivity::onExit() { + library::invalidateLibraryIndex(); #ifndef SIMULATOR if (!restartRequested) Storage.endUsbDrive(); #endif diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 3d057f42a29..1fa5c2d0de9 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -38,11 +38,8 @@ #include "EpubReaderBookmarkListActivity.h" #include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderClippingListActivity.h" +#include "EpubReaderDrawerActivity.h" #include "EpubReaderFootnotesActivity.h" -#include "ReaderFontLoading.h" -#if CROSSINK_APP_CAP_TOUCH -#include "EpubReaderTouchMenuActivity.h" -#endif #include "EpubReaderPercentSelectionActivity.h" #include "EpubReaderUtils.h" #include "FocusReadingText.h" @@ -56,6 +53,7 @@ #include "ProgressMapper.h" #include "QrDisplayActivity.h" #include "QuickActions.h" +#include "ReaderFontLoading.h" #include "ReaderProgressShadow.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" @@ -63,6 +61,8 @@ #include "SilentRestart.h" #include "WordRef.h" #include "activities/home/RecentBookProgress.h" +#include "activities/reader/ControlsOptionsActivity.h" +#include "activities/settings/StatusBarSettingsActivity.h" #include "activities/util/ConfirmationActivity.h" #include "activities/util/IntervalSelectionActivity.h" #include "clippings/ClippingHighlightGeometry.h" @@ -1002,10 +1002,10 @@ ReaderViewportLayout computeReaderViewportLayout(GfxRenderer& renderer, const bo layout.marginLeft += effectiveReaderLeftMargin(); layout.marginRight += SETTINGS.screenMarginHorizontal; - const int topStatusBarReservedHeight = ReaderUtils::getTopClockStatusBarReservedHeight(renderer); + const int topStatusBarReservedHeight = ReaderUtils::getTopStatusBarReservedHeight(renderer); if (topStatusBarReservedHeight > 0) { layout.marginTop += std::max(static_cast(SETTINGS.screenMarginVertical), - topStatusBarReservedHeight + ReaderUtils::TOP_CLOCK_TEXT_PADDING); + topStatusBarReservedHeight + ReaderUtils::TOP_STATUS_BAR_TEXT_PADDING); } else { layout.marginTop += SETTINGS.screenMarginVertical; } @@ -1886,12 +1886,10 @@ bool EpubReaderActivity::estimateTimeLeftSeconds(const bool bookEstimate, uint32 return seconds > 0; } -bool EpubReaderActivity::formatTimeLeftLabel(char* buf, const size_t len) const { - if (!buf || len == 0 || SETTINGS.statusBarTimeLeft == CrossPointSettings::STATUS_BAR_TIME_LEFT::TIME_LEFT_HIDE) { +bool EpubReaderActivity::formatTimeLeftLabel(char* buf, const size_t len, const bool bookEstimate) const { + if (!buf || len == 0) { return false; } - - const bool bookEstimate = SETTINGS.statusBarTimeLeft == CrossPointSettings::STATUS_BAR_TIME_LEFT::TIME_LEFT_BOOK; uint32_t seconds = 0; if (estimateTimeLeftSeconds(bookEstimate, seconds)) { formatCompactReadingDuration(seconds, buf, len); @@ -2237,34 +2235,41 @@ bool EpubReaderActivity::beginGlobalSettingsEdit() { } void EpubReaderActivity::endGlobalSettingsEdit() { - if (!bookReaderSettingsSuspendedForGlobalEdit) { - return; - } - - // Global Settings is editing SETTINGS while the book-specific reader values - // are suspended. Retain every edited reader default before restoring this - // book, otherwise the stale snapshot is written back on a later save or - // when the reader exits. - captureReaderSettings(globalReaderSettingsBeforeBook); - // Only fields this book actually owns need the pre-edit snapshot restored, - // so its own look survives an unrelated global edit. A book that just - // inherits the global font has nothing of its own to protect there, and - // restoring the whole snapshot would revert the edit the user just made - // for the rest of this reading session. Render mode is tracked separately - // (a build fallback can set it without hasCustomReaderSettings), so it is - // restored on its own whenever this book owns it. - ReaderSettingsSnapshot effectiveSettings = globalReaderSettingsBeforeBook; - applyReaderSettingsOverrides(effectiveSettings, suspendedBookReaderSettings, - initialBookReaderSettings.readerSettingsOverrideMask); - applyReaderSettings(effectiveSettings); - if (initialBookReaderSettings.hasSafeModeOverride) { - applySafeModeReaderSettings(); + if (bookReaderSettingsSuspendedForGlobalEdit) { + // Global Settings is editing SETTINGS while the book-specific reader values + // are suspended. Retain every edited reader default before restoring this + // book, otherwise the stale snapshot is written back on a later save or + // when the reader exits. + captureReaderSettings(globalReaderSettingsBeforeBook); + // Only fields this book actually owns need the pre-edit snapshot restored, + // so its own look survives an unrelated global edit. A book that just + // inherits the global font has nothing of its own to protect there, and + // restoring the whole snapshot would revert the edit the user just made + // for the rest of this reading session. Render mode is tracked separately + // (a build fallback can set it without hasCustomReaderSettings), so it is + // restored on its own whenever this book owns it. + ReaderSettingsSnapshot effectiveSettings = globalReaderSettingsBeforeBook; + applyReaderSettingsOverrides(effectiveSettings, suspendedBookReaderSettings, + initialBookReaderSettings.readerSettingsOverrideMask); + applyReaderSettings(effectiveSettings); + if (initialBookReaderSettings.hasSafeModeOverride) { + applySafeModeReaderSettings(); + } + if (bookHasRenderModeOverride) { + SETTINGS.epubRenderMode = normalizeRenderModeRaw(suspendedBookReaderSettings.epubRenderMode); + } + captureReaderSettings(initialBookReaderSettings.readerSettings); + bookReaderSettingsSuspendedForGlobalEdit = false; } - if (bookHasRenderModeOverride) { - SETTINGS.epubRenderMode = normalizeRenderModeRaw(suspendedBookReaderSettings.epubRenderMode); + // Bar edits change the reading viewport even when the book's font settings do not. + // Rebuild the active section immediately so it cannot keep pages laid out for + // the previous top/bottom reservations. + const ReaderViewportLayout layout = computeReaderViewportLayout(renderer, automaticPageTurnActive); + if (section && (layout.viewportWidth != buildViewportWidth || layout.viewportHeight != buildViewportHeight)) { + RenderLock lock(*this); + prepareCurrentSectionForRelayout(); + section.reset(); } - captureReaderSettings(initialBookReaderSettings.readerSettings); - bookReaderSettingsSuspendedForGlobalEdit = false; } void EpubReaderActivity::saveReaderOptionsForBook(void* ctx) { @@ -2554,7 +2559,9 @@ void EpubReaderActivity::onExit() { void EpubReaderActivity::openReaderMenu() { clearPendingManualPageTurns(); - int currentPage = 0; + int chapterPage = 0; + int chapterPageCount = 0; + bool chapterPageCountEstimated = false; int totalPages = 0; float bookProgress = 0.0f; uint16_t bmSpine; @@ -2568,8 +2575,14 @@ void EpubReaderActivity::openReaderMenu() { // Serialize EPUB metadata/file access with the render task. RenderLock lock(*this); previewActive = activeFootnotePreview; - currentPage = section ? section->currentPage + 1 : 0; totalPages = section ? section->estimatedTotalPages() : 0; + chapterPageCount = totalPages; + chapterPage = section ? section->currentPage + 1 : 0; + chapterPageCountEstimated = section && (section->isBuilding() || section->isPartial()); + if (section && !previewActive) { + float chapterProgress = 0.0f; + resolveChapterGroupPageProgress(chapterPage, chapterPageCount, chapterProgress, chapterPageCountEstimated); + } bmSpine = static_cast(currentSpineIndex); bmProgress = (section && totalPages > 0) ? static_cast(section->currentPage) / totalPages : 0.0f; bookmarkPageCount = totalPages > 0 ? totalPages : 1; @@ -2584,58 +2597,52 @@ void EpubReaderActivity::openReaderMenu() { isBookCompleted = stats.isCompleted; bookProgress = getCurrentBookProgressPercent(); } - const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); - pauseReadingPaceTimer("reader_menu"); const BookReaderSettingsData bookSettings = loadBookReaderSettingsFile(epub->getCachePath()); - std::unique_ptr menuActivity; -#if CROSSINK_APP_CAP_TOUCH - if (mappedInput.hasTouchHardware()) { - menuActivity = makeUniqueNoThrow( - renderer, mappedInput, epub, touchReaderPreviewModel.get(), bookProgress, - !previewActive && !currentPageFootnotes.empty(), - !previewActive && epub && Dictionary::exists(epub->getCachePath().c_str()), !BOOKMARKS.getBookmarks().empty(), - CLIPPINGS.hasClippings(), - !previewActive && BOOKMARKS.hasBookmarkForPage(bmSpine, bmProgress, bookmarkPageCount), isBookCompleted, - SETTINGS.statusBarTimeLeft != CrossPointSettings::STATUS_BAR_TIME_LEFT::TIME_LEFT_HIDE, stableCurrentPage, - stablePageCount, getAutoPageTurnIntervalSeconds(), automaticPageTurnActive, saveReaderOptionsForBook, this, - saveGlobalSettingsForBookReader, this, beginGlobalSettingsEditForBookReader, this, - endGlobalSettingsEditForBookReader, this, bookSettings.dictionarySdFontFamilyName, - bookSettings.dictionaryFontPointSize, bookSettings.hasDictionaryFontOverride, saveDictionaryFontForBookReader, - this, touchReaderDrawerState); - if (!menuActivity) { - LOG_ERR("ERS", "Could not allocate touch reader menu"); - resumeReadingPaceTimer("reader_menu_oom"); - requestUpdate(); - return; - } + std::unique_ptr buttonPreviewModel; +#if CROSSINK_APP_READER_SAMPLE_PREVIEW + // Owned for the menu lifetime: too large for the task stack, but under 3 KiB + // instead of a captured book page plus its larger preview model. + buttonPreviewModel = makeUniqueNoThrow(); + if (!buttonPreviewModel || !buttonPreviewModel->captureParagraph(READER_PREVIEW_PARAGRAPH)) { + LOG_ERR("ERDM", "Could not prepare sample reader preview"); + buttonPreviewModel.reset(); } #endif + auto menuActivity = makeUniqueNoThrow( + renderer, mappedInput, epub, +#if CROSSINK_APP_CAP_TOUCH + touchReaderPreviewModel.get(), +#else + nullptr, +#endif + bookProgress, static_cast(chapterPage), static_cast(chapterPageCount), + chapterPageCountEstimated, !previewActive && !currentPageFootnotes.empty(), + !previewActive && Dictionary::exists(epub->getCachePath().c_str()), !BOOKMARKS.getBookmarks().empty(), + CLIPPINGS.hasClippings(), !previewActive && BOOKMARKS.hasBookmarkForPage(bmSpine, bmProgress, bookmarkPageCount), + isBookCompleted, + (SETTINGS.topReaderStatusBar.contains(ReaderStatusBarItem::TimeLeftBook) || + SETTINGS.topReaderStatusBar.contains(ReaderStatusBarItem::TimeLeftChapter) || + SETTINGS.bottomReaderStatusBar.contains(ReaderStatusBarItem::TimeLeftBook) || + SETTINGS.bottomReaderStatusBar.contains(ReaderStatusBarItem::TimeLeftChapter)), + stableCurrentPage, stablePageCount, getAutoPageTurnIntervalSeconds(), automaticPageTurnActive, + saveReaderOptionsForBook, this, saveGlobalSettingsForBookReader, this, beginGlobalSettingsEditForBookReader, this, + endGlobalSettingsEditForBookReader, this, bookSettings.dictionarySdFontFamilyName, + bookSettings.dictionaryFontPointSize, bookSettings.hasDictionaryFontOverride, saveDictionaryFontForBookReader, + this, touchReaderDrawerState, std::move(buttonPreviewModel)); if (!menuActivity) { - menuActivity = makeUniqueNoThrow( - renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, SETTINGS.orientation, - !previewActive && !currentPageFootnotes.empty(), - !previewActive && epub && Dictionary::exists(epub->getCachePath().c_str()), !BOOKMARKS.getBookmarks().empty(), - CLIPPINGS.hasClippings(), - !previewActive && BOOKMARKS.hasBookmarkForPage(bmSpine, bmProgress, bookmarkPageCount), isBookCompleted, - automaticPageTurnActive, getAutoPageTurnIntervalSeconds(), - SETTINGS.statusBarTimeLeft != CrossPointSettings::STATUS_BAR_TIME_LEFT::TIME_LEFT_HIDE, - saveReaderOptionsForBook, this, saveGlobalSettingsForBookReader, this, beginGlobalSettingsEditForBookReader, - this, stableCurrentPage, stablePageCount, endGlobalSettingsEditForBookReader, this, - bookSettings.dictionarySdFontFamilyName, bookSettings.dictionaryFontPointSize, - bookSettings.hasDictionaryFontOverride, saveDictionaryFontForBookReader, this); - if (!menuActivity) { - LOG_ERR("ERS", "Could not allocate reader menu"); - resumeReadingPaceTimer("reader_menu_oom"); - requestUpdate(); - return; - } + LOG_ERR("ERS", "Could not allocate reader drawer"); + resumeReadingPaceTimer("reader_menu_oom"); + requestUpdate(); + return; } startActivityForResult(std::move(menuActivity), [this](const ActivityResult& result) { +#if !CROSSINK_APP_CAP_TOUCH + const auto heapAfterMenu = MemoryBudget::snapshot(); + LOG_DBG("ERDM", "Button drawer returned: free=%u maxAlloc=%u", heapAfterMenu.freeHeap, heapAfterMenu.maxAllocHeap); +#endif if (const auto* chapter = std::get_if(&result.data)) { -#if CROSSINK_APP_CAP_TOUCH touchReaderDrawerState = chapter->drawerState; -#endif applyOrientation(chapter->orientation); if (chapter->settingsChanged) { ensureReaderSdFontLoaded(renderer); @@ -2676,9 +2683,7 @@ void EpubReaderActivity::openReaderMenu() { requestUpdate(); return; } -#if CROSSINK_APP_CAP_TOUCH touchReaderDrawerState = menu->reopenDrawer ? menu->drawerState : ReaderDrawerState{}; -#endif applyOrientation(menu->orientation); if (menu->settingsChanged && hasReaderSettingsChange(menu->changeMask, ReaderSettingsChangeMask::Relayout)) { ensureReaderSdFontLoaded(renderer); @@ -2689,7 +2694,7 @@ void EpubReaderActivity::openReaderMenu() { resumeReadingPaceTimer("reader_menu_return"); if (!result.isCancelled) { if (menu->action == static_cast(EpubReaderMenuAction::GO_TO_PERCENT) && menu->drawerValue >= 0) { - // The touch drawer's Percent pane reports centipercent (see EpubReaderTouchMenuActivity::percent). + // The touch drawer's Percent pane reports centipercent (see EpubReaderDrawerActivity::percent). jumpToPercent(static_cast(menu->drawerValue) / 100.0f); return; } @@ -2701,18 +2706,29 @@ void EpubReaderActivity::openReaderMenu() { setAutoPageTurnIntervalSeconds(static_cast(menu->drawerValue)); return; } - const auto action = static_cast(menu->action); - const bool returnToReaderMenu = - shouldReopenTouchReaderDrawer(menu->reopenDrawer, mappedInput.hasTouchHardware()) || - !mappedInput.hasTouchHardware(); - onReaderMenuConfirm(action, returnToReaderMenu); -#if CROSSINK_APP_CAP_TOUCH - if (shouldReopenTouchReaderDrawer(menu->reopenDrawer, mappedInput.hasTouchHardware()) && + const auto action = static_cast(menu->action); + const bool returnToReaderMenu = menu->reopenDrawer; + if (action == EpubReaderMenuAction::SYNC || action == EpubReaderMenuAction::NEARBY_POSITION_SYNC || + action == EpubReaderMenuAction::SEND_NEARBY_BOOK) { + PendingOverlayResume resume; + resume.origin = PendingOverlayOrigin::Reader; + resume.overlay = PendingOverlayType::ReaderDrawer; + resume.tab = static_cast(menu->drawerState.tab); + resume.pane = static_cast(menu->drawerState.pane); + resume.selectedIndex = menu->drawerState.selectedIndex; + resume.scrollPosition = menu->drawerState.pane == ReaderDrawerPane::Root + ? menu->drawerState.rootTopIndex[static_cast(menu->drawerState.tab)] + : menu->drawerState.paneTopIndex; + resume.bookPath = epub->getPath(); + onReaderMenuConfirm(action, returnToReaderMenu, &resume); + } else { + onReaderMenuConfirm(action, returnToReaderMenu); + } + if (menu->reopenDrawer && (action == EpubReaderMenuAction::BOOKMARK_TOGGLE || action == EpubReaderMenuAction::TOGGLE_COMPLETED || action == EpubReaderMenuAction::RESET_READING_PACE)) { openReaderMenu(); } -#endif } }); } @@ -2742,12 +2758,14 @@ bool EpubReaderActivity::backgroundSectionBuildHasHeap() { } void EpubReaderActivity::idlePrewarmNextPage() { + RenderLock lock(*this, RenderLock::Mode::Try); + if (!lock.ownsLock()) return; + if (!section || section->isBuilding() || activeFootnotePreview || automaticPageTurnActive || - !renderer.hasFrameBuffer() || RenderLock::peek() || lastRenderCompleteMs == 0 || + !renderer.hasFrameBuffer() || lastRenderCompleteMs == 0 || (millis() - lastRenderCompleteMs) < IDLE_SD_FONT_PREWARM_DELAY_MS) { return; } - RenderLock lock(*this); prewarmNextPageFonts("Idle"); prerenderNextPage(); } @@ -2924,8 +2942,14 @@ void EpubReaderActivity::loop() { #endif const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); - if (touch.tapped && - ReaderUtils::isBottomStatusBarTap(renderer, touch.y, UITheme::getInstance().getStatusBarHeight())) { + const int bottomTapHeight = + automaticPageTurnActive + ? std::max(UITheme::getStatusBarHeight(), + UITheme::getProgressBarHeight() + UITheme::getInstance().getMetrics().statusBarVerticalMargin) + : UITheme::getStatusBarHeight(); + if (touch.tapped && (ReaderUtils::isBottomStatusBarTap(renderer, touch.y, bottomTapHeight) || + ReaderUtils::isTopStatusBarTap( + renderer, touch.y, UITheme::getReaderStatusBarHeight(ReaderStatusBarPosition::Top)))) { if (SETTINGS.tapToHideStatusBar) { statusBarVisible = !statusBarVisible; requestUpdate(); @@ -2972,59 +2996,65 @@ void EpubReaderActivity::loop() { // IncreMENTAL resumes a reopened partial immediately; Incremental waits until the reader is // close enough to its watermark to avoid rebuilding far ahead of the current session. - if (!backgroundBuildYieldForInput.load(std::memory_order_relaxed) && section && !section->isBuilding() && - section->isPartial() && !RenderLock::peek() && buildViewportWidth > 0 && !partialRebuildStartFailed && - !partialRebuildAbortedForLowMemory && - (SETTINGS.indexingMethod != CrossPointSettings::INDEXING_INCREMENTAL || - section->currentPage + PARTIAL_REBUILD_START_MARGIN >= static_cast(section->pageCount))) { - RenderLock lock(*this); - releaseGrayscaleStripScratch(); - if (section && !section->isBuilding() && section->isPartial() && backgroundSectionBuildHasHeap()) { - const int renderFontId = activeSectionFontId != 0 ? activeSectionFontId : SETTINGS.getReaderFontId(); - const SectionBuildProfile profile = buildProfileForRenderMode(normalizeRenderMode(SETTINGS.epubRenderMode)); - if (!section->startBuild( - readerRenderSpecForProfile(renderFontId, buildViewportWidth, buildViewportHeight, profile))) { - partialRebuildStartFailed = true; - LOG_ERR("ERS", "Failed to start deferred partial extension build"); - } else { - LOG_DBG("ERS", "Resuming partial extension build (%d/%d)", section->currentPage, section->pageCount); + { + RenderLock lock(*this, RenderLock::Mode::Try); + if (lock.ownsLock() && !backgroundBuildYieldForInput.load(std::memory_order_relaxed) && section && + !section->isBuilding() && section->isPartial() && buildViewportWidth > 0 && !partialRebuildStartFailed && + !partialRebuildAbortedForLowMemory && + (SETTINGS.indexingMethod != CrossPointSettings::INDEXING_INCREMENTAL || + section->currentPage + PARTIAL_REBUILD_START_MARGIN >= static_cast(section->pageCount))) { + releaseGrayscaleStripScratch(); + if (backgroundSectionBuildHasHeap()) { + const int renderFontId = activeSectionFontId != 0 ? activeSectionFontId : SETTINGS.getReaderFontId(); + const SectionBuildProfile profile = buildProfileForRenderMode(normalizeRenderMode(SETTINGS.epubRenderMode)); + if (!section->startBuild( + readerRenderSpecForProfile(renderFontId, buildViewportWidth, buildViewportHeight, profile))) { + partialRebuildStartFailed = true; + LOG_ERR("ERS", "Failed to start deferred partial extension build"); + } else { + LOG_DBG("ERS", "Reader near partial watermark (%d/%d), resuming extension build", section->currentPage, + section->pageCount); + } } } } - // Drive any in-progress section build forward, off the page-turn critical path. Responsiveness comes - // from ticking one page at a time (BACKGROUND_BUILD_PAGES_PER_TICK), skipping while the render mutex is - // busy so we never delay a pending render, and yielding whenever input is pending - // (backgroundBuildYieldForInput). Incremental only ticks within BUILD_WINDOW_AHEAD of the reader (or - // while catching up); IncreMENTAL runs the build to completion (see sectionBuildWantsTick()). - // The lock-free sectionBuildWantsTick() pre-check keeps idle iterations from taking RenderLock and - // freeing the grayscale strip scratch, which the next anti-aliased page would have to reallocate. - if (!backgroundBuildYieldForInput.load(std::memory_order_relaxed) && sectionBuildWantsTick() && !RenderLock::peek()) { - RenderLock lock(*this); - // Re-check under the lock: render() may have finalized the build between the pre-check and - // acquiring the lock here. - const bool boundedBuildCanTick = - SETTINGS.indexingMethod != CrossPointSettings::INDEXING_INCREMENTAL || - (section && (section->isPartial() || section->activeBuildHasCaughtReadablePages())); - const bool tickDue = section && sectionBuildWantsTick() && boundedBuildCanTick; - // Layout needs the heap the strip scratch holds; only give it up for a tick that is due. - if (tickDue) releaseGrayscaleStripScratch(); - if (tickDue && backgroundSectionBuildHasHeap()) { - if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) { - LOG_ERR("ERS", "Background section build failed"); - if (section->lastBuildLayoutAbortedForLowMemory() && section->pageCount > 0) { - partialRebuildAbortedForLowMemory = true; - LOG_ERR("ERS", "Background section build suspended for low heap; not retrying for this section"); + // Drive any in-progress incremental section build forward, off the page-turn critical path, + // but only within a small window ahead of the reader: an unbounded build monopolized the + // RenderLock and locked out page turns. The build follows the reader instead, and instant + // reopen comes from suspendBuild() persisting the laid-out pages as a partial on exit. + // Try the render mutex before inspecting section state; skip optional work + // while rendering so input can be polled again without waiting for the lock. + // While extending a partial, pageCount is pinned at the partial watermark until the rebuild + // catches up, so keep ticking it even before activeBuildHasCaughtReadablePages() turns true; + // the window check below would compare against the pinned watermark and stall the catch-up. + // Once the extension has caught up, the window applies again: without that, a resumed partial + // rebuilt its whole chapter in one hot-loop burst instead of following the reader. + // sectionBuildWantsTick() holds the catch-up/window logic and is shared with + // skipLoopDelay(), so the loop only runs hot while a tick can actually happen. + { + RenderLock lock(*this, RenderLock::Mode::Try); + if (lock.ownsLock() && !backgroundBuildYieldForInput.load(std::memory_order_relaxed) && sectionBuildWantsTick() && + (SETTINGS.indexingMethod != CrossPointSettings::INDEXING_INCREMENTAL || section->isPartial() || + section->activeBuildHasCaughtReadablePages())) { + releaseGrayscaleStripScratch(); + if (backgroundSectionBuildHasHeap()) { + if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) { + LOG_ERR("ERS", "Background section build failed"); + if (section->lastBuildLayoutAbortedForLowMemory() && section->pageCount > 0) { + partialRebuildAbortedForLowMemory = true; + LOG_ERR("ERS", "Background section build suspended for low heap; not retrying for this section"); + return; + } + section.reset(); + requestUpdate(); return; } - section.reset(); - requestUpdate(); - return; - } - if (section->isBuildComplete()) { - const bool repositioned = applyDeferredReposition(); - (void)repositioned; - requestUpdate(); + if (section->isBuildComplete()) { + const bool repositioned = applyDeferredReposition(); + (void)repositioned; + requestUpdate(); + } } } } @@ -3228,6 +3258,7 @@ void EpubReaderActivity::loop() { if (SETTINGS.longPressMenuAction != CrossPointSettings::LONG_MENU_OFF && mappedInput.getHeldTime() >= longPressMenuMs) { const auto action = static_cast(SETTINGS.longPressMenuAction); + if (action == CrossPointSettings::LONG_MENU_LIBRARY) mappedInput.suppressNextConfirmRelease(); executeReaderQuickAction(action); return; } @@ -3237,6 +3268,7 @@ void EpubReaderActivity::loop() { mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= longPressMenuMs) { longPressMenuHandled = true; const auto action = static_cast(SETTINGS.longPressMenuAction); + if (action == CrossPointSettings::LONG_MENU_LIBRARY) mappedInput.suppressNextConfirmRelease(); executeReaderQuickAction(action); return; } @@ -3888,12 +3920,29 @@ void EpubReaderActivity::openWordSelect(bool framebufferContainsPage, int initia }); } -void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action, const bool returnToReaderMenu, +void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuAction action, const bool returnToReaderMenu, const PendingOverlayResume* replacementResume) { switch (action) { - case EpubReaderMenuActivity::MenuAction::STATUS_BAR_SETTINGS: + case EpubReaderMenuAction::STATUS_BAR_SETTINGS: { + auto statusBar = makeUniqueNoThrow(renderer, mappedInput, true, + epub && epub->getReferencePageCount() > 0); + if (!statusBar) { + LOG_ERR("ERS", "Could not allocate status bar settings"); + requestUpdate(); + break; + } + beginGlobalSettingsEdit(); + startActivityForResult(std::move(statusBar), [this, returnToReaderMenu](const ActivityResult&) { + saveGlobalSettingsPreservingBookOverrides(); + endGlobalSettingsEdit(); + if (returnToReaderMenu) + openReaderMenu(); + else + requestUpdate(); + }); break; - case EpubReaderMenuActivity::MenuAction::SEND_NEARBY_BOOK: { + } + case EpubReaderMenuAction::SEND_NEARBY_BOOK: { if (!epub) break; const int page = section ? section->currentPage : nextPageNumber; const int pageCount = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount; @@ -3904,10 +3953,12 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); break; } - activityManager.goToNearbyBookSend(epub->getPath(), true); + if (activityManager.goToNearbyBookSend(epub->getPath(), true) && replacementResume) { + APP_STATE.setPendingOverlayResume(*replacementResume); + } return; } - case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: { + case EpubReaderMenuAction::SELECT_CHAPTER: { const int spineIdx = currentSpineIndex; const std::string path = epub->getPath(); pauseReadingPaceTimer("chapter_selection"); @@ -3938,7 +3989,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction }); break; } - case EpubReaderMenuActivity::MenuAction::FOOTNOTES: { + case EpubReaderMenuAction::FOOTNOTES: { pauseReadingPaceTimer("footnotes"); startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), [this, returnToReaderMenu](const ActivityResult& result) { @@ -3953,7 +4004,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction }); break; } - case EpubReaderMenuActivity::MenuAction::GO_TO_PERCENT: { + case EpubReaderMenuAction::GO_TO_PERCENT: { float bookProgress = 0.0f; { // Serialize EPUB metadata/file access with the render task. @@ -3973,9 +4024,37 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction }); break; } - case EpubReaderMenuActivity::MenuAction::GO_TO_STABLE_PAGE: + case EpubReaderMenuAction::GO_TO_STABLE_PAGE: { + uint32_t page = 0; + uint32_t count = 0; + { + RenderLock lock(*this); + count = epub ? epub->getReferencePageCount() : 0; + if (count > 0 && section) { + const int total = section->estimatedTotalPages(); + const float progress = total > 0 ? static_cast(section->currentPage) / total : 0.0f; + epub->resolveReferencePage(currentSpineIndex, progress, page, count); + } + } + auto selector = makeUniqueNoThrow(renderer, mappedInput, page, count); + if (!selector) { + LOG_ERR("ERS", "Could not allocate stable page selector"); + requestUpdate(); + break; + } + pauseReadingPaceTimer("stable_page_selection"); + startActivityForResult(std::move(selector), [this, returnToReaderMenu](const ActivityResult& result) { + if (!result.isCancelled) { + jumpToStablePage(std::get(result.data).page); + } else if (returnToReaderMenu) { + openReaderMenu(); + } else { + requestUpdate(); + } + }); break; - case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: { + } + case EpubReaderMenuAction::DISPLAY_QR: { if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) { auto p = section->loadPage(section->currentPage); if (p) { @@ -4009,15 +4088,15 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); break; } - case EpubReaderMenuActivity::MenuAction::SAVE_CLIPPING: { + case EpubReaderMenuAction::SAVE_CLIPPING: { startClipSelection(); break; } - case EpubReaderMenuActivity::MenuAction::LOOKUP: { + case EpubReaderMenuAction::LOOKUP: { openWordSelect(/*framebufferContainsPage=*/false); break; } - case EpubReaderMenuActivity::MenuAction::LOOKUP_HISTORY: { + case EpubReaderMenuAction::LOOKUP_HISTORY: { pauseReadingPaceTimer("lookup_history"); const BookReaderSettingsData bookSettings = loadBookReaderSettingsFile(epub->getCachePath()); startActivityForResult(std::make_unique(renderer, mappedInput, epub->getCachePath(), @@ -4025,30 +4104,30 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction bookSettings.dictionaryFontPointSize), [this, returnToReaderMenu](const ActivityResult&) { resumeReadingPaceTimer("lookup_history_return"); - if (returnToReaderMenu && mappedInput.hasTouchHardware()) + if (returnToReaderMenu) openReaderMenu(); else requestUpdate(); }); break; } - case EpubReaderMenuActivity::MenuAction::SET_BOOK_DICTIONARY: { + case EpubReaderMenuAction::SET_BOOK_DICTIONARY: { pauseReadingPaceTimer("dictionary_select"); startActivityForResult(std::make_unique(renderer, mappedInput, epub->getCachePath()), [this, returnToReaderMenu](const ActivityResult&) { resumeReadingPaceTimer("dictionary_select_return"); - if (returnToReaderMenu && mappedInput.hasTouchHardware()) + if (returnToReaderMenu) openReaderMenu(); else requestUpdate(); }); break; } - case EpubReaderMenuActivity::MenuAction::GO_HOME: { + case EpubReaderMenuAction::GO_HOME: { onGoHome(); return; } - case EpubReaderMenuActivity::MenuAction::DELETE_STATS: { + case EpubReaderMenuAction::DELETE_STATS: { pauseReadingPaceTimer("delete_stats_confirm"); startActivityForResult(std::make_unique(renderer, mappedInput, confirmationHeading(StrId::STR_DELETE_BOOK_STATS), @@ -4073,14 +4152,14 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } } resumeReadingPaceTimer("delete_stats_return"); - if (result.isCancelled && returnToReaderMenu && mappedInput.hasTouchHardware()) + if (result.isCancelled && returnToReaderMenu) openReaderMenu(); else requestUpdate(); }); break; } - case EpubReaderMenuActivity::MenuAction::DELETE_CACHE: { + case EpubReaderMenuAction::DELETE_CACHE: { pauseReadingPaceTimer("delete_cache_confirm"); startActivityForResult( std::make_unique(renderer, mappedInput, confirmationHeading(StrId::STR_DELETE_CACHE), @@ -4088,7 +4167,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction [this, returnToReaderMenu](const ActivityResult& result) { if (result.isCancelled) { resumeReadingPaceTimer("delete_cache_cancel"); - if (returnToReaderMenu && mappedInput.hasTouchHardware()) + if (returnToReaderMenu) openReaderMenu(); else requestUpdate(); @@ -4123,14 +4202,14 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction }); break; } - case EpubReaderMenuActivity::MenuAction::RESET_READING_PACE: { + case EpubReaderMenuAction::RESET_READING_PACE: { resetReadingPaceData(); drawToast(renderer, tr(STR_READING_PACE_RESET)); delay(1000); requestUpdate(); break; } - case EpubReaderMenuActivity::MenuAction::RESET_BOOK_READER_SETTINGS: { + case EpubReaderMenuAction::RESET_BOOK_READER_SETTINGS: { pauseReadingPaceTimer("reset_reader_settings_confirm"); auto confirmation = makeUniqueNoThrow( renderer, mappedInput, confirmationHeading(StrId::STR_RESET_BOOK_READER_SETTINGS), @@ -4138,7 +4217,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction if (!confirmation) { LOG_ERR("ERS", "Could not allocate reader settings reset confirmation"); resumeReadingPaceTimer("reset_reader_settings_alloc_failed"); - if (returnToReaderMenu && mappedInput.hasTouchHardware()) + if (returnToReaderMenu) openReaderMenu(); else requestUpdate(); @@ -4147,7 +4226,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction startActivityForResult(std::move(confirmation), [this, returnToReaderMenu](const ActivityResult& result) { if (result.isCancelled) { resumeReadingPaceTimer("reset_reader_settings_cancelled"); - if (returnToReaderMenu && mappedInput.hasTouchHardware()) + if (returnToReaderMenu) openReaderMenu(); else requestUpdate(); @@ -4184,14 +4263,14 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction LOG_ERR("ERS", "Could not reset reader settings without an open book"); } resumeReadingPaceTimer("reset_reader_settings_return"); - if (!settingsReset && returnToReaderMenu && mappedInput.hasTouchHardware()) + if (!settingsReset && returnToReaderMenu) openReaderMenu(); else requestUpdate(); }); break; } - case EpubReaderMenuActivity::MenuAction::SCREENSHOT: { + case EpubReaderMenuAction::SCREENSHOT: { { RenderLock lock(*this); pendingScreenshot = true; @@ -4199,7 +4278,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); break; } - case EpubReaderMenuActivity::MenuAction::READING_STATS: { + case EpubReaderMenuAction::READING_STATS: { // Include elapsed time from the current session in the display stats. BookReadingStats displayStats = stats; if (SETTINGS.shouldTrackReadingStats()) { @@ -4235,22 +4314,26 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } break; } - case EpubReaderMenuActivity::MenuAction::TOGGLE_COMPLETED: { + case EpubReaderMenuAction::TOGGLE_COMPLETED: { const bool markCompleted = !stats.isCompleted; setBookCompleted(markCompleted); showCompletedFeedback(markCompleted); requestUpdate(); break; } - case EpubReaderMenuActivity::MenuAction::SYNC: { + case EpubReaderMenuAction::SYNC: { if (!KOREADER_STORE.hasCredentials()) { pauseReadingPaceTimer("koreader_settings"); // Authenticate / Sign Up there restart into network mode. saveProgressBeforeRestart(); startActivityForResult(std::make_unique(renderer, mappedInput), - [this](const ActivityResult&) { + [this, returnToReaderMenu](const ActivityResult&) { resumeReadingPaceTimer("koreader_settings_return"); saveGlobalSettingsPreservingBookOverrides(); + if (returnToReaderMenu) + openReaderMenu(); + else + requestUpdate(); }); } else { const int currentPage = section ? section->currentPage : nextPageNumber; @@ -4283,7 +4366,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } break; } - case EpubReaderMenuActivity::MenuAction::NEARBY_POSITION_SYNC: { + case EpubReaderMenuAction::NEARBY_POSITION_SYNC: { const int currentPage = section ? section->currentPage : nextPageNumber; const int totalPages = section ? section->estimatedTotalPages() : std::max(1, cachedChapterTotalPageCount); std::optional paragraphIndex; @@ -4353,7 +4436,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction activityManager.replaceActivity(std::move(syncActivity)); break; } - case EpubReaderMenuActivity::MenuAction::BOOKMARK_TOGGLE: { + case EpubReaderMenuAction::BOOKMARK_TOGGLE: { const int bookmarkPageCount = section ? section->estimatedTotalPages() : 0; if (activeFootnotePreview || !section || bookmarkPageCount == 0 || section->pageCount == 0) break; const uint16_t spine = static_cast(currentSpineIndex); @@ -4388,7 +4471,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); break; } - case EpubReaderMenuActivity::MenuAction::VIEW_BOOKMARKS: { + case EpubReaderMenuAction::VIEW_BOOKMARKS: { pauseReadingPaceTimer("bookmark_list"); startActivityForResult( std::make_unique(renderer, mappedInput, BOOKMARKS.getBookmarks()), @@ -4423,7 +4506,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction pauseReadingPaceTimer("bookmark_jump"); } else { resumeReadingPaceTimer("bookmark_list_cancel"); - if (returnToReaderMenu && mappedInput.hasTouchHardware()) { + if (returnToReaderMenu) { openReaderMenu(); return; } @@ -4432,7 +4515,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction }); break; } - case EpubReaderMenuActivity::MenuAction::VIEW_CLIPPINGS: { + case EpubReaderMenuAction::VIEW_CLIPPINGS: { pauseReadingPaceTimer("clipping_list"); startActivityForResult(std::make_unique(renderer, mappedInput), [this, returnToReaderMenu](const ActivityResult& result) { @@ -4441,7 +4524,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction handleClippingJump(clipping); } else { resumeReadingPaceTimer("clipping_list_cancel"); - if (returnToReaderMenu && mappedInput.hasTouchHardware()) { + if (returnToReaderMenu) { openReaderMenu(); return; } @@ -4450,7 +4533,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction }); break; } - case EpubReaderMenuActivity::MenuAction::DELETE_BOOKMARKS: { + case EpubReaderMenuAction::DELETE_BOOKMARKS: { pauseReadingPaceTimer("delete_bookmarks_confirm"); startActivityForResult( std::make_unique(renderer, mappedInput, @@ -4461,19 +4544,35 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction BOOKMARKS.clearAll(); } resumeReadingPaceTimer(result.isCancelled ? "delete_bookmarks_cancel" : "delete_bookmarks_return"); - if (result.isCancelled && returnToReaderMenu && mappedInput.hasTouchHardware()) + if (result.isCancelled && returnToReaderMenu) openReaderMenu(); else requestUpdate(); }); break; } - case EpubReaderMenuActivity::MenuAction::AUTO_PAGE_TURN: + case EpubReaderMenuAction::AUTO_PAGE_TURN: openAutoPageTurnIntervalPicker(false, returnToReaderMenu); break; - case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN: - case EpubReaderMenuActivity::MenuAction::READER_OPTIONS: - case EpubReaderMenuActivity::MenuAction::CONTROLS_OPTIONS: + case EpubReaderMenuAction::CONTROLS_OPTIONS: { + auto controls = makeUniqueNoThrow(renderer, mappedInput); + if (!controls) { + LOG_ERR("ERS", "Could not allocate controls settings"); + requestUpdate(); + break; + } + beginGlobalSettingsEdit(); + startActivityForResult(std::move(controls), [this, returnToReaderMenu](const ActivityResult&) { + endGlobalSettingsEdit(); + if (returnToReaderMenu) + openReaderMenu(); + else + requestUpdate(); + }); + break; + } + case EpubReaderMenuAction::ROTATE_SCREEN: + case EpubReaderMenuAction::READER_OPTIONS: break; } } @@ -4520,6 +4619,16 @@ bool EpubReaderActivity::getFrontlightPanelBookDetails(FrontlightPanelBookDetail details.chapter = epub->getTocItem(tocIndex).title; } details.progressPercent = clampPercent(static_cast(getCurrentBookProgressPercent() + 0.5f)); + int chapterPage = section ? section->currentPage + 1 : 0; + int chapterPageCount = section ? section->estimatedTotalPages() : 0; + bool chapterPageCountEstimated = section && (section->isBuilding() || section->isPartial()); + if (section && !activeFootnotePreview) { + float chapterProgress = 0.0f; + resolveChapterGroupPageProgress(chapterPage, chapterPageCount, chapterProgress, chapterPageCountEstimated); + } + details.chapterPage = static_cast(chapterPage); + details.chapterPageCount = static_cast(chapterPageCount); + details.chapterPageCountEstimated = chapterPageCountEstimated; return true; } @@ -4565,11 +4674,11 @@ bool EpubReaderActivity::handleFrontlightPanelResult(const FrontlightPanelResult resume.readerOrientation = SETTINGS.orientation; resume.preserveReaderOrientation = true; // Use the reader's save-and-handoff path; a direct network restart skips onExit(). - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::SYNC, false, &resume); + onReaderMenuConfirm(EpubReaderMenuAction::SYNC, false, &resume); return true; } if (result.action == FrontlightPanelAction::NearbyPositionSync) { - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::NEARBY_POSITION_SYNC, false, &resume); + onReaderMenuConfirm(EpubReaderMenuAction::NEARBY_POSITION_SYNC, false, &resume); return true; } if (!activityManager.goToNearbyBookSend(epub->getPath(), true)) return false; @@ -4578,13 +4687,12 @@ bool EpubReaderActivity::handleFrontlightPanelResult(const FrontlightPanelResult } bool EpubReaderActivity::handleExternalReaderMenuAction(const uint8_t action) { - if (action != static_cast(EpubReaderMenuActivity::MenuAction::NEARBY_POSITION_SYNC)) return false; - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::NEARBY_POSITION_SYNC); + if (action != static_cast(EpubReaderMenuAction::NEARBY_POSITION_SYNC)) return false; + onReaderMenuConfirm(EpubReaderMenuAction::NEARBY_POSITION_SYNC); return true; } bool EpubReaderActivity::restorePendingOverlay(const PendingOverlayResume& resume) { -#if CROSSINK_APP_CAP_TOUCH if (!epub || resume.overlay != PendingOverlayType::ReaderDrawer || resume.bookPath != epub->getPath()) return false; touchReaderDrawerState.tab = static_cast(std::min(resume.tab, static_cast(ReaderDrawerTab::Count) - 1)); @@ -4593,10 +4701,6 @@ bool EpubReaderActivity::restorePendingOverlay(const PendingOverlayResume& resum restoreReaderDrawerScroll(touchReaderDrawerState, resume.scrollPosition); openReaderMenu(); return true; -#else - (void)resume; - return false; -#endif } void EpubReaderActivity::reindexCurrentSection() { @@ -4655,7 +4759,7 @@ void EpubReaderActivity::openAutoPageTurnIntervalPicker(const bool ignoreInitial } else { resumeReadingPaceTimer("auto_turn_interval_cancel"); } - if (returnToReaderMenu && mappedInput.hasTouchHardware()) + if (returnToReaderMenu) openReaderMenu(); else requestUpdate(); @@ -5056,14 +5160,14 @@ void EpubReaderActivity::executeReaderQuickAction(CrossPointSettings::LONG_PRESS reindexCurrentSection(); break; case CrossPointSettings::LONG_MENU_TOGGLE_BOOKMARK: - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::BOOKMARK_TOGGLE); + onReaderMenuConfirm(EpubReaderMenuAction::BOOKMARK_TOGGLE); break; case CrossPointSettings::LONG_MENU_REFRESH_SCREEN: prepareManualRefresh(); requestUpdate(); break; case CrossPointSettings::LONG_MENU_SYNC_PROGRESS: - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::SYNC); + onReaderMenuConfirm(EpubReaderMenuAction::SYNC); break; case CrossPointSettings::LONG_MENU_MARK_FINISHED: { const bool newCompleted = !stats.isCompleted; @@ -5073,10 +5177,10 @@ void EpubReaderActivity::executeReaderQuickAction(CrossPointSettings::LONG_PRESS requestUpdate(); break; case CrossPointSettings::LONG_MENU_READING_STATS: - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::READING_STATS); + onReaderMenuConfirm(EpubReaderMenuAction::READING_STATS); break; case CrossPointSettings::LONG_MENU_SCREENSHOT: - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::SCREENSHOT); + onReaderMenuConfirm(EpubReaderMenuAction::SCREENSHOT); break; case CrossPointSettings::LONG_MENU_CYCLE_PAGE_TURN: openAutoPageTurnIntervalPicker(mappedInput.isPressed(MappedInputManager::Button::Confirm)); @@ -5121,6 +5225,9 @@ void EpubReaderActivity::executeReaderQuickAction(CrossPointSettings::LONG_PRESS case CrossPointSettings::LONG_MENU_FILE_BROWSER: activityManager.goToFileBrowser(epub ? epub->getPath() : ""); break; + case CrossPointSettings::LONG_MENU_LIBRARY: + activityManager.goToLibrary(); + break; case CrossPointSettings::LONG_MENU_CREATE_CLIPPING: startClipSelection(); break; @@ -5157,7 +5264,7 @@ bool EpubReaderActivity::handleShortcutAction(const uint8_t rawAction) { requestManualPageTurn(false, "home_button"); return true; case CrossPointSettings::SHORT_PWRBTN::NEARBY_POSITION_SYNC: - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::NEARBY_POSITION_SYNC); + onReaderMenuConfirm(EpubReaderMenuAction::NEARBY_POSITION_SYNC); return true; case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FONT: executeReaderQuickAction(CrossPointSettings::LONG_MENU_CHANGE_FONT); @@ -5222,6 +5329,8 @@ bool EpubReaderActivity::handleShortcutAction(const uint8_t rawAction) { case CrossPointSettings::SHORT_PWRBTN::QUICK_ACTIONS: openQuickActionsPopup(); return true; + case CrossPointSettings::SHORT_PWRBTN::LIBRARY: + return handleGlobalPowerButtonAction(action); case CrossPointSettings::SHORT_PWRBTN::QUICK_LOCK: case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FRONTLIGHT: case CrossPointSettings::SHORT_PWRBTN::TOGGLE_TOUCHSCREEN: @@ -5243,7 +5352,7 @@ bool EpubReaderActivity::handleShortcutAction(const CrossPointSettings::SHORT_PW requestManualPageTurn(false, "shortcut"); return true; case CrossPointSettings::SHORT_PWRBTN::NEARBY_POSITION_SYNC: - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::NEARBY_POSITION_SYNC); + onReaderMenuConfirm(EpubReaderMenuAction::NEARBY_POSITION_SYNC); return true; case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FONT: executeReaderQuickAction(CrossPointSettings::LONG_MENU_CHANGE_FONT); @@ -5308,6 +5417,8 @@ bool EpubReaderActivity::handleShortcutAction(const CrossPointSettings::SHORT_PW case CrossPointSettings::SHORT_PWRBTN::QUICK_ACTIONS: openQuickActionsPopup(); return true; + case CrossPointSettings::SHORT_PWRBTN::LIBRARY: + return handleGlobalPowerButtonAction(action); case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FRONTLIGHT: case CrossPointSettings::SHORT_PWRBTN::TOGGLE_TOUCHSCREEN: return handleGlobalPowerButtonAction(action); @@ -5330,7 +5441,7 @@ void EpubReaderActivity::openQuickActionsPopup() { return; } if (action == CrossPointSettings::SHORT_PWRBTN::SYNC_PROGRESS) { - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::SYNC); + onReaderMenuConfirm(EpubReaderMenuAction::SYNC); return; } dispatchShortcutAction(action); @@ -5379,7 +5490,7 @@ bool EpubReaderActivity::executeShortPowerButtonAction() { switch (SETTINGS.shortPwrBtn) { case CrossPointSettings::SHORT_PWRBTN::NEARBY_POSITION_SYNC: - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::NEARBY_POSITION_SYNC); + onReaderMenuConfirm(EpubReaderMenuAction::NEARBY_POSITION_SYNC); return true; case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FONT: executeReaderQuickAction(CrossPointSettings::LONG_MENU_CHANGE_FONT); @@ -5446,6 +5557,8 @@ bool EpubReaderActivity::executeShortPowerButtonAction() { case CrossPointSettings::SHORT_PWRBTN::QUICK_ACTIONS: openQuickActionsPopup(); return true; + case CrossPointSettings::SHORT_PWRBTN::LIBRARY: + return handleGlobalPowerButtonAction(static_cast(SETTINGS.shortPwrBtn)); case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FRONTLIGHT: case CrossPointSettings::SHORT_PWRBTN::TOGGLE_TOUCHSCREEN: // The global dispatcher pauses briefly after wake, so retain the reader @@ -5506,7 +5619,7 @@ bool EpubReaderActivity::executeLongPowerButtonAction() { return true; case CrossPointSettings::SHORT_PWRBTN::NEARBY_POSITION_SYNC: mappedInput.suppressNextPowerConfirmRelease(); - onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::NEARBY_POSITION_SYNC); + onReaderMenuConfirm(EpubReaderMenuAction::NEARBY_POSITION_SYNC); return true; case CrossPointSettings::SHORT_PWRBTN::MARK_FINISHED: executeReaderQuickAction(CrossPointSettings::LONG_MENU_MARK_FINISHED); @@ -5560,6 +5673,8 @@ bool EpubReaderActivity::executeLongPowerButtonAction() { case CrossPointSettings::SHORT_PWRBTN::QUICK_ACTIONS: openQuickActionsPopup(); return true; + case CrossPointSettings::SHORT_PWRBTN::LIBRARY: + return handleGlobalPowerButtonAction(static_cast(SETTINGS.longPwrBtn)); default: return false; } @@ -5767,9 +5882,8 @@ void EpubReaderActivity::setAutoPageTurnIntervalSeconds(uint16_t seconds) { pageTurnDuration = static_cast(seconds) * 1000UL; automaticPageTurnActive = true; - const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); - // resets cached section so that space is reserved for auto page turn indicator when None or progress bar only - if (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight()) { + // Reserve a text lane for the auto-turn label when the configured bottom bar has none. + if (!ReaderUtils::bottomStatusBarHasTextLane()) { // Preserve current reading position so we can restore after reflow. RenderLock lock(*this); prepareCurrentSectionForRelayout(); @@ -7925,34 +8039,28 @@ void EpubReaderActivity::renderStatusBar() const { resolveChapterGroupPageProgress(currentPage, pageCount, chapterProgress, pageCountEstimated); } + const auto topBar = SETTINGS.readerStatusBar(ReaderStatusBarPosition::Top); + const auto bottomBar = SETTINGS.readerStatusBar(ReaderStatusBarPosition::Bottom); + const auto uses = [&](const ReaderStatusBarItem item) { return topBar.contains(item) || bottomBar.contains(item); }; + uint32_t referencePage = 0; uint32_t referencePageCount = 0; - if (activeFootnotePreview || !SETTINGS.stablePageNumbers || + if (activeFootnotePreview || !uses(ReaderStatusBarItem::StablePageNumber) || !epub->resolveReferencePage(currentSpineIndex, sectionProgress, referencePage, referencePageCount)) { referencePage = 0; referencePageCount = 0; } - std::string title; - - int textYOffset = 0; + std::string chapterTitle; + char autoTurnLabel[96] = {}; if (automaticPageTurnActive) { // Fixed-shape label on a per-page-render path: format on the stack instead of // allocating a std::to_string temporary and a concatenation result each time. // Sized for the longest translated prefix (Kazakh, 53 bytes) plus the interval // digits, so no locale is cut short or sliced mid-codepoint. - char autoTurnLabel[96]; snprintf(autoTurnLabel, sizeof(autoTurnLabel), "%s%lu", tr(STR_AUTO_TURN_ENABLED), pageTurnDuration / 1000); - title = autoTurnLabel; - - // calculates textYOffset when rendering title in status bar - const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); - - // offsets text if no status bar or progress bar only - if (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight()) { - textYOffset += UITheme::getInstance().getMetrics().statusBarVerticalMargin; - } + chapterTitle = autoTurnLabel; } #if CROSSINK_APP_CAP_TOUCH @@ -7960,23 +8068,20 @@ void EpubReaderActivity::renderStatusBar() const { // The touch header owns the preview title; keep the footer from repeating it. } #else - else if (activeFootnotePreview && SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { - title = tr(STR_FOOTNOTES); + else if (activeFootnotePreview && uses(ReaderStatusBarItem::TitleChapter)) { + chapterTitle = tr(STR_FOOTNOTES); } #endif - else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) { - title = tr(STR_UNNAMED); + else if (uses(ReaderStatusBarItem::TitleChapter)) { + chapterTitle = tr(STR_UNNAMED); int titleSpineIndex = currentSpineIndex; int groupLastSpineIndex = currentSpineIndex; epub->resolveChapterGroupRange(currentSpineIndex, titleSpineIndex, groupLastSpineIndex); const int tocIndex = epub->getTocIndexForSpineIndex(titleSpineIndex); if (tocIndex != -1) { const auto tocItem = epub->getTocItem(tocIndex); - title = tocItem.title; + chapterTitle = tocItem.title; } - - } else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE) { - title = epub->getTitle(); } const int bookmarkPageCount = estimatedPageCount > 0 ? estimatedPageCount : 1; @@ -7986,14 +8091,40 @@ void EpubReaderActivity::renderStatusBar() const { const bool bookmarked = !activeFootnotePreview && BOOKMARKS.hasBookmarkForPage(static_cast(currentSpineIndex), rawProgress, bookmarkPageCount); - char timeLeftLabel[24] = {}; - const char* timeLeft = - (!activeFootnotePreview && formatTimeLeftLabel(timeLeftLabel, sizeof(timeLeftLabel))) ? timeLeftLabel : nullptr; - GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title.c_str(), 0, textYOffset, bookmarked, timeLeft, - ReaderUtils::readerDarkModeEnabled(), chapterProgress * 100.0f, static_cast(referencePage), - static_cast(referencePageCount), !activeFootnotePreview, pageCountEstimated); - GUI.drawTopStatusBarClock(renderer, UITheme::getInstance().getMetrics().topPadding, nullptr, true, 0, - ReaderUtils::readerDarkModeEnabled()); + char bookTime[24] = {}; + char chapterTime[24] = {}; + ReaderStatusBarContent content; + content.bookProgress = bookProgress; + content.chapterProgress = chapterProgress * 100.0f; + content.chapterPage = currentPage; + content.chapterPageCount = pageCount; + content.stablePage = static_cast(referencePage); + content.stablePageCount = static_cast(referencePageCount); + const char* bookTitle = uses(ReaderStatusBarItem::TitleBook) ? epub->getTitle().c_str() : nullptr; + if (activeFootnotePreview) { +#if CROSSINK_APP_CAP_TOUCH + bookTitle = nullptr; +#else + bookTitle = tr(STR_FOOTNOTES); +#endif + } + content.bookTitle = automaticPageTurnActive ? autoTurnLabel : bookTitle; + content.chapterTitle = chapterTitle.c_str(); + content.timeLeftBook = !activeFootnotePreview && uses(ReaderStatusBarItem::TimeLeftBook) && + formatTimeLeftLabel(bookTime, sizeof(bookTime), true) + ? bookTime + : nullptr; + content.timeLeftChapter = !activeFootnotePreview && uses(ReaderStatusBarItem::TimeLeftChapter) && + formatTimeLeftLabel(chapterTime, sizeof(chapterTime), false) + ? chapterTime + : nullptr; + content.bookmarked = bookmarked; + content.showProgress = !activeFootnotePreview; + content.pageCountEstimated = pageCountEstimated; + content.darkMode = ReaderUtils::readerDarkModeEnabled(); + content.autoTurnLabel = automaticPageTurnActive ? autoTurnLabel : nullptr; + GUI.drawReaderStatusBar(renderer, ReaderStatusBarPosition::Top, content); + GUI.drawReaderStatusBar(renderer, ReaderStatusBarPosition::Bottom, content); } void EpubReaderActivity::refreshChapterGroupEstimate(const uint16_t viewportWidth, const uint16_t viewportHeight) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 47511b68f1b..7c978f00be1 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -16,15 +16,15 @@ #include "BookReadingStats.h" #include "BookmarkStore.h" #include "EndOfBookOptions.h" -#include "EpubReaderMenuActivity.h" +#include "EpubReaderMenuModel.h" #include "GlobalReadingStats.h" #include "ManualPageTurnQueue.h" #include "ReaderProgressSaveDebouncer.h" #include "activities/Activity.h" +#include "activities/reader/TouchReaderPreviewModel.h" #include "components/OptionPopup.h" #if CROSSINK_APP_CAP_TOUCH #include "activities/reader/ReaderPinchGesture.h" -#include "activities/reader/TouchReaderPreviewModel.h" #endif struct ToastRect { @@ -200,8 +200,8 @@ class EpubReaderActivity final : public Activity { bool pendingReferenceUnitsAreCharacters = false; std::optional pendingResolvedReferencePage; uint16_t pendingParagraphIndex = UINT16_MAX; -#if CROSSINK_APP_CAP_TOUCH ReaderDrawerState touchReaderDrawerState{}; +#if CROSSINK_APP_CAP_TOUCH std::unique_ptr touchReaderPreviewModel; bool touchReaderPreviewAllocationAttempted = false; #endif @@ -403,7 +403,7 @@ class EpubReaderActivity final : public Activity { bool estimateRemainingTimeLeftPages(bool bookEstimate, float& remainingPages) const; bool estimateProgressTimeLeftSeconds(uint32_t& seconds) const; bool estimateTimeLeftSeconds(bool bookEstimate, uint32_t& seconds) const; - bool formatTimeLeftLabel(char* buf, size_t len) const; + bool formatTimeLeftLabel(char* buf, size_t len, bool bookEstimate) const; void refreshCachedTimeLeftEstimate(); void applyBookStatsEditsFromDisk(); void handleBookStatsReturn(bool returnToReaderMenu); @@ -455,7 +455,7 @@ class EpubReaderActivity final : public Activity { bool autoLookupInitialWord = false); std::unique_ptr reloadDictionaryLookupPage(int pageOffset = 0); static std::unique_ptr reloadDictionaryLookupPageCallback(void* context, int pageOffset); - void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action, bool returnToReaderMenu = false, + void onReaderMenuConfirm(EpubReaderMenuAction action, bool returnToReaderMenu = false, const PendingOverlayResume* replacementResume = nullptr); // Opens the reader menu for the current position (short-press Confirm) void openReaderMenu(); @@ -510,6 +510,11 @@ class EpubReaderActivity final : public Activity { return true; } bool preventAutoSleep() override { return automaticPageTurnActive; } + // Hold the loop hot only while the build has work this loop would do: a kept-alive + // build sitting outside the lookahead window is dormant, and reporting it here would + // pin the CPU at full clock (no power saving, yield-only loop) for the whole read. + // Mirrors the tick condition in loop(): catch-up phase, or watermark inside the window. + // Caller must own RenderLock: render() can replace or finalize section. bool sectionBuildWantsTick() const { if (!section || !section->isBuilding()) { return false; diff --git a/src/activities/reader/EpubReaderTouchMenuActivity.cpp b/src/activities/reader/EpubReaderDrawerActivity.cpp similarity index 67% rename from src/activities/reader/EpubReaderTouchMenuActivity.cpp rename to src/activities/reader/EpubReaderDrawerActivity.cpp index 185bcf73052..eda0764bba8 100644 --- a/src/activities/reader/EpubReaderTouchMenuActivity.cpp +++ b/src/activities/reader/EpubReaderDrawerActivity.cpp @@ -1,15 +1,15 @@ -#include "EpubReaderTouchMenuActivity.h" - -#if CROSSINK_APP_CAP_TOUCH +#include "EpubReaderDrawerActivity.h" #include #include #include #include +#include #include #include #include #include +#include #include #include @@ -21,6 +21,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "DeviceCapabilities.h" #include "MappedInputManager.h" #include "ReaderFontLoading.h" #include "ReaderUtils.h" @@ -33,15 +34,18 @@ #endif #include "components/DrawerHandle.h" #include "components/SliderValue.h" +#include "components/TouchHeaderBackButton.h" #include "components/UITheme.h" #include "components/UIThemeTokens.h" #include "components/UiAppHelpers.h" #include "components/icons/keyboardIcons.h" #include "components/icons/listIcons.h" #include "components/icons/touchHeaderIcons.h" +#include "fontIds.h" #include "util/Dictionary.h" #include "util/DictionaryRegistry.h" #include "util/FontFamilyLabel.h" +#include "util/ReaderBookProgress.h" namespace fui = freeink::ui; @@ -51,6 +55,8 @@ constexpr int16_t SLIDER_CONTROL_HEIGHT = 30; constexpr int16_t SLIDER_CAPTION_GAP = 15; constexpr int16_t COMPACT_SLIDER_CAPTION_GAP = 5; constexpr int16_t SLIDER_SCALE_GAP = 4; +constexpr int16_t BUTTON_SLIDER_FOCUS_PADDING_X = 10; +constexpr int16_t BUTTON_SLIDER_FOCUS_PADDING_Y = 4; constexpr int16_t DRAWER_SIDE_INSET = 16; constexpr int16_t DRAWER_SCROLLBAR_RIGHT_INSET = 4; constexpr int16_t DRAWER_LIST_TOP_PADDING = 5; @@ -67,9 +73,10 @@ constexpr int LANDSCAPE_ROOT_ROWS = 4; // not a grid key, so it does not need a sentinel here. constexpr int16_t KEYPAD_DOT = -1; constexpr int16_t KEYPAD_OK = -2; +constexpr unsigned long PERCENT_KEYPAD_LONG_PRESS_MS = 1000; constexpr uint8_t PORTRAIT_DRAWER_HEIGHT_PERCENT = 50; // Non-root landscape panes retain a little more room than portrait. Root -// height is calculated from four actual theme rows in buildDrawer(). +// height is calculated from the rows reserved in drawerHeight(). constexpr uint8_t LANDSCAPE_DRAWER_HEIGHT_PERCENT = 65; constexpr uint8_t LANDSCAPE_DUAL_SLIDER_DRAWER_HEIGHT_PERCENT = 75; @@ -113,6 +120,8 @@ struct ReaderSliderRowProps { fui::ActionId decrement = fui::NO_ACTION; fui::ActionId increment = fui::NO_ACTION; int16_t captionGap = SLIDER_CAPTION_GAP; + bool focused = false; + bool editing = false; bool enabled = true; }; @@ -144,9 +153,19 @@ void drawReaderSliderRow(fui::Screen& screen, const ReaderSlide const int16_t height = readerSliderRowHeight(screen, row); const fui::Rect rect = screen.takeTop(height); fui::TextStyle labelStyle = screen.theme().bodyText; - labelStyle.bold = false; + labelStyle.bold = row.editing; fui::TextStyle valueStyle = screen.theme().bodyText; valueStyle.bold = true; + if (row.focused) { + const fui::Rect focusRect{static_cast(rect.x - BUTTON_SLIDER_FOCUS_PADDING_X), + static_cast(rect.y - BUTTON_SLIDER_FOCUS_PADDING_Y), + static_cast(rect.width + BUTTON_SLIDER_FOCUS_PADDING_X * 2), + static_cast(rect.height + BUTTON_SLIDER_FOCUS_PADDING_Y * 2)}; + if (row.editing) + screen.target().fill(focusRect, fui::Paint::dither(fui::Color::LightGray), 4); + else + screen.target().stroke(focusRect, fui::Paint::solid(fui::Color::Black), 1, 4); + } const int16_t lineHeight = screen.target().lineHeight(labelStyle.font); const int16_t captionHeight = row.label ? static_cast(lineHeight + row.captionGap) : 0; @@ -233,27 +252,81 @@ int16_t centeredReaderSliderControlTop(fui::Screen& screen, con return std::max(0, static_cast((screen.body().height - SLIDER_CONTROL_HEIGHT) / 2 - controlOffset)); } +template +void drawButtonSliderStepHints(fui::Screen& screen, const int fineStep, const int coarseStep, + const char* unit) { + fui::TextStyle hint = screen.theme().smallText; + hint.align = fui::TextAlign::Center; + const int16_t lineHeight = screen.target().lineHeight(hint.font); + char line[64]; + std::snprintf(line, sizeof(line), "%s %d%s", tr(STR_STEP_HINT_FRONT), fineStep, unit); + screen.target().text(screen.takeTop(lineHeight, screen.theme().spaceSm), line, hint); + std::snprintf(line, sizeof(line), "%s %d%s", tr(STR_STEP_HINT_SIDE), coarseStep, unit); + screen.target().text(screen.takeTop(lineHeight), line, hint); +} + template void drawDualReaderSliderRows(fui::Screen& screen, const ReaderSliderRowProps& first, - const ReaderSliderRowProps& second) { + const ReaderSliderRowProps& second, const bool showButtonHints) { const int16_t bottomPadding = screen.theme().spaceSm; + fui::TextStyle hint = screen.theme().smallText; + hint.align = fui::TextAlign::Center; + char frontHint[64] = {}; + char sideHint[64] = {}; + bool inlineHints = false; + if (showButtonHints) { + std::snprintf(frontHint, sizeof(frontHint), "%s +/- 1", tr(STR_STEP_HINT_FRONT)); + std::snprintf(sideHint, sizeof(sideHint), "%s +/- 5", tr(STR_STEP_HINT_SIDE)); + const int16_t halfWidth = static_cast(screen.body().width / 2); + inlineHints = screen.target().measureText(hint.font, frontHint, hint).width <= halfWidth && + screen.target().measureText(hint.font, sideHint, hint).width <= halfWidth; + } + const int16_t hintLineHeight = showButtonHints ? screen.target().lineHeight(screen.theme().smallText.font) : 0; + const int16_t hintRows = inlineHints ? 1 : 2; + const int16_t hintHeight = + showButtonHints ? static_cast((hintLineHeight + screen.theme().spaceSm) * hintRows) : 0; const int16_t remaining = std::max(0, static_cast(screen.body().height - readerSliderRowHeight(screen, first) - - readerSliderRowHeight(screen, second) - bottomPadding)); + readerSliderRowHeight(screen, second) - hintHeight - bottomPadding)); drawReaderSliderRow(screen, first); - // Keep the controls evenly separated while reserving a small lane beneath - // the second scale so its endpoint labels do not sit on the tab-bar rule. - screen.spacer(remaining); + // On button devices, put most of the spare space below the second slider so + // the two settings read as a group and the help text stays distinct. + const int16_t betweenRows = showButtonHints ? static_cast(remaining / 3) : remaining; + screen.spacer(betweenRows); drawReaderSliderRow(screen, second); + if (showButtonHints) { + screen.spacer(static_cast(remaining - betweenRows)); + screen.spacer(screen.theme().spaceSm); + const fui::Rect hintRow = screen.takeTop(hintLineHeight); + if (inlineHints) { + const int16_t halfWidth = static_cast(hintRow.width / 2); + screen.target().text(fui::Rect{hintRow.x, hintRow.y, halfWidth, hintRow.height}, frontHint, hint); + screen.target().text(fui::Rect{static_cast(hintRow.x + halfWidth), hintRow.y, + static_cast(hintRow.width - halfWidth), hintRow.height}, + sideHint, hint); + } else { + screen.target().text(hintRow, frontHint, hint); + screen.spacer(screen.theme().spaceSm); + screen.target().text(screen.takeTop(hintLineHeight), sideHint, hint); + } + } screen.spacer(bottomPadding); } +void setDrawerSelectionStyle(fui::StyleSet& styles) { + styles = fui::defaultListRowStyles(); + styles.selected.background = fui::Paint::dither(fui::Color::LightGray); + styles.selected.foreground = fui::Paint::solid(fui::Color::Black); + styles.selected.radius = 4; +} + uint16_t configureDrawerList(fui::ListProps& props, const fui::ThemeTokens& theme, const fui::Rect bounds) { // Root rows use SettingRowProps, whose content starts at the drawer inset // plus its 8px side padding. Do not inherit themed list pills here: their // additional row inset makes picker labels visibly farther to the right. props.rowInset = 0; props.sidePadding = fui::SettingRowProps{}.sidePadding; + setDrawerSelectionStyle(props.rowStyles); return configureUiList(props, theme, bounds); } @@ -300,9 +373,10 @@ int indexForRaw(const std::array& rawValues, const uint8_t value) { } } // namespace -EpubReaderTouchMenuActivity::EpubReaderTouchMenuActivity( +EpubReaderDrawerActivity::EpubReaderDrawerActivity( GfxRenderer& renderer, MappedInputManager& mappedInput, std::shared_ptr epub, - const TouchReaderPreviewModel* previewModel, const float bookProgressPercent, const bool hasFootnotes, + const EpubReaderPreviewModel* previewModel, const float bookProgressPercent, const uint32_t chapterPage, + const uint32_t chapterPageCount, const bool chapterPageCountEstimated, const bool hasFootnotes, const bool hasDictionary, const bool hasBookmarks, const bool hasClippings, const bool isCurrentPageBookmarked, const bool isBookCompleted, const bool showReadingPaceReset, const uint32_t stableCurrentPage, const uint32_t stablePageCount, const uint16_t autoPageTurnIntervalSeconds, const bool automaticPageTurnActive, @@ -313,10 +387,12 @@ EpubReaderTouchMenuActivity::EpubReaderTouchMenuActivity( ReaderOptionsActivity::GlobalSettingsEditCallback endGlobalSettingsEditCallback, void* endGlobalSettingsEditContext, const char* dictionaryFontFamilyName, const uint8_t dictionaryFontPointSize, const bool hasDictionaryFontOverride, ReaderOptionsActivity::DictionaryFontChangedCallback dictionaryFontChangedCallback, - void* dictionaryFontChangedContext, const ReaderDrawerState& initialState) - : Activity("EpubReaderTouchMenu", renderer, mappedInput), + void* dictionaryFontChangedContext, const ReaderDrawerState& initialState, + std::unique_ptr ownedPreviewModel) + : Activity("EpubReaderDrawer", renderer, mappedInput), epub(std::move(epub)), previewModel(previewModel), + ownedPreviewModel(std::move(ownedPreviewModel)), // Round to the nearest whole percent, not the nearest centipercent, so the pane // opens on a clean number (e.g. "43.00%") rather than the book's exact fractional // position; the keypad is how the user reaches a decimal destination. @@ -324,6 +400,9 @@ EpubReaderTouchMenuActivity::EpubReaderTouchMenuActivity( stablePage(clampStablePage(stableCurrentPage, stablePageCount)), stablePageCount(stablePageCount), percentSeed(percent), + chapterPage(chapterPage), + chapterPageCount(chapterPageCount), + chapterPageCountEstimated(chapterPageCountEstimated), stablePageSeed(stablePage), hasFootnotes(hasFootnotes), hasDictionary(hasDictionary), @@ -338,6 +417,7 @@ EpubReaderTouchMenuActivity::EpubReaderTouchMenuActivity( state(initialState), draft(captureSettings()), sourceSettings(draft), + lastGoodPreviewSettings(draft), saveReaderSettingsCallback(saveReaderSettingsCallback), saveReaderSettingsContext(saveReaderSettingsContext), saveGlobalSettingsCallback(saveGlobalSettingsCallback), @@ -355,42 +435,59 @@ EpubReaderTouchMenuActivity::EpubReaderTouchMenuActivity( if (dictionaryFontFamilyName) { std::strncpy(this->dictionaryFontFamilyName, dictionaryFontFamilyName, sizeof(this->dictionaryFontFamilyName) - 1); } + if (!this->previewModel) this->previewModel = this->ownedPreviewModel.get(); + previewUnavailable = !mappedInput.hasTouchHardware() && !this->previewModel; + // The button menu paints the whole screen, including its sample preview. + previewDirty = !mappedInput.hasTouchHardware(); } -void EpubReaderTouchMenuActivity::onEnter() { +void EpubReaderDrawerActivity::onEnter() { Activity::onEnter(); - mappedInput.setReaderTouchscreenOverride(true); + if (mappedInput.hasTouchHardware()) mappedInput.setReaderTouchscreenOverride(true); - const ReaderDrawerCatalog catalog = makeReaderDrawerCatalog( - {hasFootnotes, hasDictionary, hasBookmarks, hasClippings, showReadingPaceReset, stablePageCount > 0}); + const ReaderDrawerCatalog catalog = + makeReaderDrawerCatalog({hasFootnotes, hasDictionary, hasBookmarks, hasClippings, showReadingPaceReset, + stablePageCount > 0, !mappedInput.hasTouchHardware()}); for (size_t tab = 0; tab < rootRows.size(); ++tab) { rootRows[tab].reserve(catalog[tab].count); rootRows[tab].assign(catalog[tab].items.begin(), catalog[tab].items.begin() + catalog[tab].count); } + if (!mappedInput.hasTouchHardware()) { + buttonFocusActive = state.pane != ReaderDrawerPane::Root; + if (state.pane == ReaderDrawerPane::Root) { + const auto count = rootRows[static_cast(state.tab)].size(); + state.selectedIndex = + static_cast(std::clamp(state.selectedIndex, 0, std::max(0, static_cast(count) - 1))); + } + } paneRows.reserve(7); discoverDictionaries(); - applySharedUiTheme(app, uiTarget); - app.on(ACTION_ROW, &EpubReaderTouchMenuActivity::onRowEvent, this); - app.on(ACTION_TAB, &EpubReaderTouchMenuActivity::onTabEvent, this); - app.on(ACTION_DISMISS, &EpubReaderTouchMenuActivity::onDismissEvent, this); - app.on(ACTION_BACK, &EpubReaderTouchMenuActivity::onBackEvent, this); - app.on(ACTION_SLIDER, &EpubReaderTouchMenuActivity::onSliderEvent, this); - app.on(ACTION_STEP, &EpubReaderTouchMenuActivity::onStepEvent, this); - app.on(ACTION_SLIDER + 3, &EpubReaderTouchMenuActivity::onSliderEvent, this); - app.on(ACTION_STEP + 3, &EpubReaderTouchMenuActivity::onStepEvent, this); - app.on(ACTION_CONFIRM, &EpubReaderTouchMenuActivity::onConfirmEvent, this); - app.on(ACTION_KEYPAD_KEY, &EpubReaderTouchMenuActivity::onKeypadKeyEvent, this); - app.on(ACTION_KEYPAD_BACKSPACE, &EpubReaderTouchMenuActivity::onKeypadBackspaceEvent, this); + app.on(ACTION_ROW, &EpubReaderDrawerActivity::onRowEvent, this); + app.on(ACTION_TAB, &EpubReaderDrawerActivity::onTabEvent, this); + app.on(ACTION_DISMISS, &EpubReaderDrawerActivity::onDismissEvent, this); + app.on(ACTION_BACK, &EpubReaderDrawerActivity::onBackEvent, this); + app.on(ACTION_SLIDER, &EpubReaderDrawerActivity::onSliderEvent, this); + app.on(ACTION_STEP, &EpubReaderDrawerActivity::onStepEvent, this); + app.on(ACTION_SLIDER + 3, &EpubReaderDrawerActivity::onSliderEvent, this); + app.on(ACTION_STEP + 3, &EpubReaderDrawerActivity::onStepEvent, this); + app.on(ACTION_CONFIRM, &EpubReaderDrawerActivity::onConfirmEvent, this); + app.on(ACTION_KEYPAD_KEY, &EpubReaderDrawerActivity::onKeypadKeyEvent, this); + app.on(ACTION_KEYPAD_BACKSPACE, &EpubReaderDrawerActivity::onKeypadBackspaceEvent, this); if (app.handlerOverflowed()) { LOG_ERR("ERDM", "Touch menu handler table overflowed; a control's action is silently dead"); } - app.setScreen(&EpubReaderTouchMenuActivity::drawerScreen, this); + app.setScreen(&EpubReaderDrawerActivity::drawerScreen, this); requestUpdate(); } -void EpubReaderTouchMenuActivity::onExit() { +void EpubReaderDrawerActivity::onExit() { commitSettings(); + ownedPreviewModel.reset(); + if (!mappedInput.hasTouchHardware()) { + [[maybe_unused]] const auto heap = MemoryBudget::snapshot(); + LOG_DBG("ERDM", "Button preview released: free=%u maxAlloc=%u", heap.freeHeap, heap.maxAllocHeap); + } dictionaryRegistry.clear(); // The reader remains active beneath this drawer. Keep the small catalog for // its resident scalable family so reopening Font Size avoids an SD rescan. @@ -398,11 +495,11 @@ void EpubReaderTouchMenuActivity::onExit() { if (!sdFontSystem.hasResidentScalableFamily(SETTINGS.sdFontFamilyName)) #endif sdFontSystem.releaseRegistry(); - mappedInput.setReaderTouchscreenOverride(false); + if (mappedInput.hasTouchHardware()) mappedInput.setReaderTouchscreenOverride(false); Activity::onExit(); } -void EpubReaderTouchMenuActivity::discoverFonts() { +void EpubReaderDrawerActivity::discoverFonts() { RenderLock lock; GUI.drawPopup(renderer, tr(STR_LOADING_POPUP), true); sdFontSystem.refreshIfDirty(); @@ -422,7 +519,7 @@ void EpubReaderTouchMenuActivity::discoverFonts() { } } -void EpubReaderTouchMenuActivity::refreshTtfRenderingRow() { +void EpubReaderDrawerActivity::refreshTtfRenderingRow() { paneRows.erase(std::remove(paneRows.begin(), paneRows.end(), RowId::TtfRendering), paneRows.end()); #if CROSSINK_SCALABLE_FONTS if (state.pane == ReaderDrawerPane::ReaderFont && sdFontSystem.isScalableFamily(draft.sdFontFamilyName.data())) { @@ -433,7 +530,7 @@ void EpubReaderTouchMenuActivity::refreshTtfRenderingRow() { } #if CROSSINK_SCALABLE_FONTS -void EpubReaderTouchMenuActivity::rebuildTtfRenderingRows() { +void EpubReaderDrawerActivity::rebuildTtfRenderingRows() { paneRows = {RowId::TtfHinting, RowId::TtfRaster}; if (ttfRenderProfile.hinting == 1) paneRows.push_back(RowId::TtfInterpreter); paneRows.push_back(RowId::TtfWeight); @@ -444,7 +541,7 @@ void EpubReaderTouchMenuActivity::rebuildTtfRenderingRows() { state.paneTopIndex = std::min(state.paneTopIndex, static_cast(paneRows.size() - 1)); } -void EpubReaderTouchMenuActivity::saveTtfRenderingProfile() { +void EpubReaderDrawerActivity::saveTtfRenderingProfile() { const char* const family = draft.sdFontFamilyName.data(); if (ttfRenderProfile == TTF_RENDER_PROFILES.profileFor(family)) return; if (!TTF_RENDER_PROFILES.setProfile(family, ttfRenderProfile)) { @@ -454,7 +551,7 @@ void EpubReaderTouchMenuActivity::saveTtfRenderingProfile() { ttfRenderingChanged = ttfRenderProfile != initialTtfRenderProfile; } -void EpubReaderTouchMenuActivity::finishTtfRenderingEdit() { +void EpubReaderDrawerActivity::finishTtfRenderingEdit() { if (!ttfRenderingChanged) return; if (sdFontSystem.reloadActiveScalableFamily(renderer, draft.sdFontFamilyName.data())) { previewFontMetricsChanged = true; @@ -463,7 +560,7 @@ void EpubReaderTouchMenuActivity::finishTtfRenderingEdit() { ttfRenderingChanged = false; } -void EpubReaderTouchMenuActivity::showTtfRenderingOptions(const RowId row) { +void EpubReaderDrawerActivity::showTtfRenderingOptions(const RowId row) { using TtfRow = TtfRenderOptionsActivity::Row; TtfRow optionRow; switch (row) { @@ -495,7 +592,7 @@ void EpubReaderTouchMenuActivity::showTtfRenderingOptions(const RowId row) { } #endif -void EpubReaderTouchMenuActivity::discoverDictionaries() { +void EpubReaderDrawerActivity::discoverDictionaries() { dictionaryRegistry.discover(); dictionaryLabels.clear(); dictionaryPaths.clear(); @@ -510,8 +607,16 @@ void EpubReaderTouchMenuActivity::discoverDictionaries() { bookDictionaryPath = epub ? Dictionary::readDictPath(epub->getCachePath().c_str()) : std::string{}; } -void EpubReaderTouchMenuActivity::commitSettings() { +void EpubReaderDrawerActivity::commitSettings() { if (!settingsChanged) return; + if (!mappedInput.hasTouchHardware() && draft.sdFontFamilyName[0] != '\0' && + (draft.sdFontFamilyName != lastGoodPreviewSettings.sdFontFamilyName || + draft.readerFontPointSize != lastGoodPreviewSettings.readerFontPointSize)) { + // A queued preview may not have rendered before Back or an external exit. + // Save only an SD font whose preview already loaded and prewarmed. + LOG_ERR("ERDM", "Selected SD font was not previewed; retaining previous reader font"); + restoreReaderDraftFont(draft, lastGoodPreviewSettings); + } applySettings(draft); if (saveReaderSettingsCallback) { saveReaderSettingsCallback(saveReaderSettingsContext); @@ -521,7 +626,7 @@ void EpubReaderTouchMenuActivity::commitSettings() { settingsChanged = false; } -ReaderSettingsDraft EpubReaderTouchMenuActivity::captureSettings() { +ReaderSettingsDraft EpubReaderDrawerActivity::captureSettings() { ReaderSettingsDraft value; value.fontFamily = SETTINGS.fontFamily; value.readerFontPointSize = SETTINGS.readerFontPointSize; @@ -546,7 +651,7 @@ ReaderSettingsDraft EpubReaderTouchMenuActivity::captureSettings() { return value; } -void EpubReaderTouchMenuActivity::applySettings(const ReaderSettingsDraft& value) { +void EpubReaderDrawerActivity::applySettings(const ReaderSettingsDraft& value) { SETTINGS.fontFamily = value.fontFamily; SETTINGS.readerFontPointSize = value.readerFontPointSize; std::strncpy(SETTINGS.sdFontFamilyName, value.sdFontFamilyName.data(), sizeof(SETTINGS.sdFontFamilyName) - 1); @@ -570,15 +675,15 @@ void EpubReaderTouchMenuActivity::applySettings(const ReaderSettingsDraft& value SETTINGS.indexingMethod = value.indexingMethod; } -void EpubReaderTouchMenuActivity::markSettingChanged(const ReaderSettingsChangeMask mask) { +void EpubReaderDrawerActivity::markSettingChanged(const ReaderSettingsChangeMask mask) { settingsChanged = true; didChangeSettings = true; previewDirty = previewDirty || hasReaderSettingsChange(mask, ReaderSettingsChangeMask::Preview); changeMask = changeMask | mask; } -void EpubReaderTouchMenuActivity::closeAndReturn(const bool cancelled, const EpubReaderMenuAction action, - const bool reopenDrawer) { +void EpubReaderDrawerActivity::closeAndReturn(const bool cancelled, const EpubReaderMenuAction action, + const bool reopenDrawer) { #if CROSSINK_SCALABLE_FONTS // ActivityManager copies the result before onExit(). Resolve a pending TTF // edit here so the reader knows its old font ID and section need replacing. @@ -586,20 +691,8 @@ void EpubReaderTouchMenuActivity::closeAndReturn(const bool cancelled, const Epu #endif const bool changed = didChangeSettings; commitSettings(); - if (!cancelled && epub && - (action == EpubReaderMenuAction::SYNC || action == EpubReaderMenuAction::NEARBY_POSITION_SYNC || - action == EpubReaderMenuAction::SEND_NEARBY_BOOK)) { - PendingOverlayResume resume; - resume.origin = PendingOverlayOrigin::Reader; - resume.overlay = PendingOverlayType::ReaderDrawer; - resume.tab = static_cast(state.tab); - resume.pane = static_cast(state.pane); - resume.selectedIndex = state.selectedIndex; - resume.scrollPosition = - state.pane == ReaderDrawerPane::Root ? state.rootTopIndex[static_cast(state.tab)] : state.paneTopIndex; - resume.bookPath = epub->getPath(); - APP_STATE.setPendingOverlayResume(std::move(resume)); - } + ownedPreviewModel.reset(); + if (!mappedInput.hasTouchHardware()) previewModel = nullptr; MenuResult menu{cancelled ? -1 : static_cast(action), draft.orientation, changed}; menu.drawerState = state; menu.changeMask = changeMask; @@ -611,40 +704,40 @@ void EpubReaderTouchMenuActivity::closeAndReturn(const bool cancelled, const Epu finish(); } -bool EpubReaderTouchMenuActivity::handleHomeGesture() { +bool EpubReaderDrawerActivity::handleHomeGesture() { closePane(); return true; } -void EpubReaderTouchMenuActivity::onRowEvent(const fui::ActionEvent& event, void* user) { - auto* self = static_cast(user); +void EpubReaderDrawerActivity::onRowEvent(const fui::ActionEvent& event, void* user) { + auto* self = static_cast(user); self->buttonFocusActive = false; self->state.selectedIndex = event.value; self->app.clearTapFlash(); self->activateListIndex(event.value); } -void EpubReaderTouchMenuActivity::onTabEvent(const fui::ActionEvent& event, void* user) { - auto* self = static_cast(user); +void EpubReaderDrawerActivity::onTabEvent(const fui::ActionEvent& event, void* user) { + auto* self = static_cast(user); if (event.value < 0 || event.value >= static_cast(READER_DRAWER_TAB_COUNT)) return; self->buttonFocusActive = false; self->app.clearTapFlash(); self->changeTab(static_cast(event.value)); } -void EpubReaderTouchMenuActivity::onDismissEvent(const fui::ActionEvent&, void* user) { - static_cast(user)->closeAndReturn(true); +void EpubReaderDrawerActivity::onDismissEvent(const fui::ActionEvent&, void* user) { + static_cast(user)->closeAndReturn(true); } -void EpubReaderTouchMenuActivity::onBackEvent(const fui::ActionEvent&, void* user) { - auto* self = static_cast(user); +void EpubReaderDrawerActivity::onBackEvent(const fui::ActionEvent&, void* user) { + auto* self = static_cast(user); self->buttonFocusActive = false; self->closePane(); } -void EpubReaderTouchMenuActivity::onSliderEvent(const fui::ActionEvent& event, void* user) { +void EpubReaderDrawerActivity::onSliderEvent(const fui::ActionEvent& event, void* user) { if (event.dragPermille < 0) return; - auto* self = static_cast(user); + auto* self = static_cast(user); self->buttonFocusActive = false; const auto tapValue = [self](const int value, const int minimum, const int maximum) { return self->sliderTapPending ? snapSliderTapValue(value, minimum, maximum, 5) : value; @@ -677,8 +770,8 @@ void EpubReaderTouchMenuActivity::onSliderEvent(const fui::ActionEvent& event, v self->requestUpdate(); } -void EpubReaderTouchMenuActivity::onStepEvent(const fui::ActionEvent& event, void* user) { - auto* self = static_cast(user); +void EpubReaderDrawerActivity::onStepEvent(const fui::ActionEvent& event, void* user) { + auto* self = static_cast(user); self->buttonFocusActive = false; if (self->state.pane == ReaderDrawerPane::AutoPageTurn) { self->adjustActiveSlider(event.value); @@ -707,8 +800,8 @@ void EpubReaderTouchMenuActivity::onStepEvent(const fui::ActionEvent& event, voi self->requestUpdate(); } -void EpubReaderTouchMenuActivity::onConfirmEvent(const fui::ActionEvent&, void* user) { - auto* self = static_cast(user); +void EpubReaderDrawerActivity::onConfirmEvent(const fui::ActionEvent&, void* user) { + auto* self = static_cast(user); self->buttonFocusActive = false; if (self->state.pane == ReaderDrawerPane::Percent) { self->completePercentSelection(); @@ -725,42 +818,88 @@ void EpubReaderTouchMenuActivity::onConfirmEvent(const fui::ActionEvent&, void* self->closePane(); } -void EpubReaderTouchMenuActivity::drawerScreen(UiApp::ScreenType& screen, void* user) { - static_cast(user)->buildDrawer(screen); +void EpubReaderDrawerActivity::drawerScreen(UiApp::ScreenType& screen, void* user) { + static_cast(user)->buildDrawer(screen); } -int16_t EpubReaderTouchMenuActivity::drawerHeight() const { +int16_t EpubReaderDrawerActivity::drawerHeight() const { + if (CROSSINK_APP_READER_SAMPLE_PREVIEW) return renderer.getScreenHeight(); fui::SheetProps sheet; sheet.ruleWidth = DRAWER_RULE_WIDTH; const int16_t grabberBand = DrawerHandle::bandHeight(sheet); - // Give root menus exactly four standard row slots in landscape. A percentage - // was subtly too short once font-derived row height and the tab chrome were - // subtracted, so some UI scales exposed only three rows. + // Size landscape root menus from their real row cadence so the tab chrome + // and grabber do not consume one of the intended visible rows. const int16_t tabBarHeight = static_cast(TAB_BAR_HEIGHT + TAB_BAR_VERTICAL_PADDING * 2); int16_t drawerHeight = static_cast(readerDrawerHeight(renderer, state.pane) + grabberBand); if (state.pane == ReaderDrawerPane::Root && isLandscapeOrientation(renderer.getOrientation())) { - drawerHeight = static_cast( - grabberBand + sheet.ruleWidth + tabBarHeight + DRAWER_LIST_TOP_PADDING + - readerDrawerListHeightForRows(LANDSCAPE_ROOT_ROWS, app.theme().rowHeight, app.theme().spaceSm)); + const int16_t rowHeight = app.theme().rowHeight; + const int16_t gap = app.theme().spaceSm; + drawerHeight = static_cast(grabberBand + sheet.ruleWidth + tabBarHeight + DRAWER_LIST_TOP_PADDING + + readerDrawerListHeightForRows(LANDSCAPE_ROOT_ROWS, rowHeight, gap)); drawerHeight = std::min(drawerHeight, renderer.getScreenHeight()); } return drawerHeight; } -void EpubReaderTouchMenuActivity::buildDrawer(UiApp::ScreenType& screen) { +fui::Rect EpubReaderDrawerActivity::previewBounds() const { + if (CROSSINK_APP_READER_SAMPLE_PREVIEW) return samplePreviewBounds; + const int16_t height = static_cast(renderer.getScreenHeight() - drawerHeight()); + return fui::Rect{0, 0, renderer.getScreenWidth(), height}; +} + +bool EpubReaderDrawerActivity::showsSamplePreview() const { + if (!CROSSINK_APP_READER_SAMPLE_PREVIEW) return false; + return readerDrawerShowsSamplePreview(state.pane, state.tab, enumOptionRow); +} + +void EpubReaderDrawerActivity::buildDrawer(UiApp::ScreenType& screen) { fui::SheetProps sheet; + const bool buttonDevice = !mappedInput.hasTouchHardware(); sheet.anchor = fui::SheetEdge::Bottom; sheet.dismissAction = ACTION_DISMISS; sheet.radius = 0; sheet.ruleWidth = DRAWER_RULE_WIDTH; const int16_t tabBarHeight = static_cast(TAB_BAR_HEIGHT + TAB_BAR_VERTICAL_PADDING * 2); - const fui::Rect sheetContent = screen.sheet(sheet, drawerHeight()); - drawerHandleRect = DrawerHandle::registerTap(screen.frame(), sheetContent, sheet, ACTION_DISMISS); + if (CROSSINK_APP_READER_SAMPLE_PREVIEW) { + screen.target().fill(screen.device().screen(), fui::Paint::solid(fui::Color::White)); + const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false); + screen.setContentMarginFromScreen(fui::Insets{ + static_cast(safe.y), static_cast(renderer.getScreenWidth() - safe.x - safe.width), + static_cast(renderer.getScreenHeight() - safe.y - safe.height), static_cast(safe.x)}); + drawerHandleRect = {}; + } else { + const fui::Rect sheetContent = screen.sheet(sheet, drawerHeight()); + drawerHandleRect = DrawerHandle::registerTap(screen.frame(), sheetContent, sheet, ACTION_DISMISS); + } + if (buttonDevice) { + const auto& metrics = UITheme::getInstance().getMetrics(); + screen.takeTop(static_cast(metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + + metrics.tabBarHeight)); + } // Give every tab row four pixels of white space above and below its icons. // The tab pill keeps its previous size so the selected background does not // become taller with the row. - const fui::Rect tabs = screen.takeBottom(tabBarHeight); - buildTabBar(screen, tabs, false); + const fui::Rect tabs = buttonDevice ? screen.takeTop(tabBarHeight) : screen.takeBottom(tabBarHeight); + buildTabBar(screen, tabs, buttonDevice); + samplePreviewBounds = {}; + // cppcheck-suppress knownConditionTrueFalse ; compile-time constant on builds without the sample preview + if (showsSamplePreview()) { + const auto& metrics = UITheme::getInstance().getMetrics(); + int previewHeight = screen.body().height * metrics.previewHeightPercent / 100; + if (readerDrawerSliderPreviewsText(state.pane)) { + // Leave both controls and both translated help lines usable in landscape. + ReaderSliderRowProps row; + row.label = row.value = row.minimumLabel = row.maximumLabel = " "; + row.captionGap = COMPACT_SLIDER_CAPTION_GAP; + const int controlsHeight = + screen.theme().headerHeight + 2 * readerSliderRowHeight(screen, row) + + 2 * (screen.target().lineHeight(screen.theme().smallText.font) + screen.theme().spaceSm) + + screen.theme().spaceSm + sheet.ruleWidth + metrics.verticalSpacing; + previewHeight = std::min(previewHeight, std::max(0, screen.body().height - controlsHeight)); + } + samplePreviewBounds = + screen.takeTop(static_cast(previewHeight), static_cast(metrics.verticalSpacing)); + } screen.insetContent(fui::Insets{sheet.ruleWidth, DRAWER_SIDE_INSET, 0, DRAWER_SIDE_INSET}); switch (state.pane) { @@ -802,8 +941,23 @@ void EpubReaderTouchMenuActivity::buildDrawer(UiApp::ScreenType& screen) { } } -void EpubReaderTouchMenuActivity::buildTabBar(UiApp::ScreenType& screen, const fui::Rect rect, - const bool drawBottomRule) { +void EpubReaderDrawerActivity::drawButtonBookHeader() { + const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false); + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect header{safe.x, safe.y + metrics.topPadding, safe.width, + TouchHeaderBackButton::height(metrics, mappedInput)}; + GUI.drawHeader(renderer, header, epub ? epub->getTitle().c_str() : "", nullptr, false, true); + + const Rect summary{safe.x, header.y + header.height, safe.width, metrics.tabBarHeight}; + char progress[96]; + formatReaderBookProgress(progress, sizeof(progress), chapterPage, chapterPageCount, chapterPageCountEstimated, + percentSeed / 100); + GUI.drawSubHeader(renderer, summary, progress); + renderer.drawLine(summary.x, summary.y + summary.height - 1, summary.x + summary.width - 1, + summary.y + summary.height - 1, 1, true); +} + +void EpubReaderDrawerActivity::buildTabBar(UiApp::ScreenType& screen, const fui::Rect rect, const bool drawBottomRule) { const std::array icons = { fui::bitmapFromIcon(icon_case_sensitive_32), fui::bitmapFromIcon(icon_text_align_start_24), fui::bitmapFromIcon(icon_ellipsis_24), fui::bitmapFromIcon(icon_bookmark_24), fui::bitmapFromIcon(icon_cog_24)}; @@ -822,6 +976,12 @@ void EpubReaderTouchMenuActivity::buildTabBar(UiApp::ScreenType& screen, const f props.tabStyles.selected.background = fui::Paint::solid(fui::Color::Black); props.tabStyles.selected.foreground = fui::Paint::solid(fui::Color::White); props.tabStyles.selected.radius = 4; + if (!mappedInput.hasTouchHardware() && (state.pane != ReaderDrawerPane::Root || buttonFocusActive)) { + props.tabStyles.selected.background = fui::Paint::solid(fui::Color::White); + props.tabStyles.selected.foreground = fui::Paint::solid(fui::Color::Black); + props.tabStyles.selected.border = fui::Paint::solid(fui::Color::Black); + props.tabStyles.selected.borderWidth = 1; + } props.tabInset = fui::Insets{static_cast(4 + TAB_BAR_VERTICAL_PADDING), 4, static_cast(8 + TAB_BAR_VERTICAL_PADDING), 4}; const int16_t ruleY = drawBottomRule ? static_cast(rect.bottom() - 1) : rect.y; @@ -829,7 +989,7 @@ void EpubReaderTouchMenuActivity::buildTabBar(UiApp::ScreenType& screen, const f fui::tabBar(screen.frame(), rect, props); } -void EpubReaderTouchMenuActivity::buildPaneHeader(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildPaneHeader(UiApp::ScreenType& screen) { fui::HeaderProps header; header.title = paneTitle(); header.centered = true; @@ -860,20 +1020,23 @@ void EpubReaderTouchMenuActivity::buildPaneHeader(UiApp::ScreenType& screen) { back); } -const std::vector& EpubReaderTouchMenuActivity::activeRows() const { +const std::vector& EpubReaderDrawerActivity::activeRows() const { return state.pane == ReaderDrawerPane::Root ? rootRows[static_cast(state.tab)] : paneRows; } -int EpubReaderTouchMenuActivity::activeTopIndex() const { +int EpubReaderDrawerActivity::activeTopIndex() const { return state.pane == ReaderDrawerPane::Root ? state.rootTopIndex[static_cast(state.tab)] : state.paneTopIndex; } -void EpubReaderTouchMenuActivity::buildRootRows(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildRootRows(UiApp::ScreenType& screen) { screen.spacer(DRAWER_LIST_TOP_PADDING); const auto& rows = activeRows(); const fui::Rect listBounds = screen.body(); - const int16_t rowHeight = screen.theme().rowHeight; - const int16_t gap = screen.theme().spaceSm; + const bool buttonDevice = !mappedInput.hasTouchHardware(); + const int16_t rowHeight = + buttonDevice ? uiListRowHeight(screen.theme(), UiListRowType::SingleLine) : screen.theme().rowHeight; + const int16_t gap = buttonDevice ? static_cast(screen.theme().spaceMd * rowHeight / screen.theme().rowHeight) + : screen.theme().spaceSm; visibleRows = std::max(1, readerDrawerVisibleRows(listBounds.height, rowHeight, gap)); int top = std::clamp(state.rootTopIndex[static_cast(state.tab)], 0, std::max(0, static_cast(rows.size()) - visibleRows)); @@ -890,6 +1053,8 @@ void EpubReaderTouchMenuActivity::buildRootRows(UiApp::ScreenType& screen) { props.drawChevron = rowShowsNavigationCaret(row); props.labelText = screen.theme().bodyText; props.valueText = screen.theme().smallText; + if (buttonDevice) props.minTouchSize = rowHeight; + setDrawerSelectionStyle(props.styles); props.state = isReaderDrawerRowFocused(buttonFocusActive, state.selectedIndex, static_cast(top + i)) ? fui::StateSelected : fui::StateNormal; @@ -899,9 +1064,9 @@ void EpubReaderTouchMenuActivity::buildRootRows(UiApp::ScreenType& screen) { toggle.checked = rowToggleValue(row); toggle.toggleAction = ACTION_ROW; toggle.toggleValue = static_cast(top + i); - screen.toggleRow(toggle); + screen.toggleRow(toggle, buttonDevice ? rowHeight : 0); } else { - screen.settingRow(props); + screen.settingRow(props, buttonDevice ? rowHeight : 0); } } fui::drawListScrollIndicator(screen.target(), drawerScrollbarBounds(listBounds), rows.size(), visibleRows, top, @@ -909,9 +1074,11 @@ void EpubReaderTouchMenuActivity::buildRootRows(UiApp::ScreenType& screen) { screen.theme().listScrollInset); } -void EpubReaderTouchMenuActivity::buildSimplePane(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildSimplePane(UiApp::ScreenType& screen) { buildPaneHeader(screen); const auto& rows = activeRows(); + const bool buttonDevice = !mappedInput.hasTouchHardware(); + const int16_t rowHeight = buttonDevice ? uiListRowHeight(screen.theme(), UiListRowType::SingleLine) : 0; for (size_t i = 0; i < rows.size(); ++i) { char value[48] = {}; fui::SettingRowProps props; @@ -922,14 +1089,16 @@ void EpubReaderTouchMenuActivity::buildSimplePane(UiApp::ScreenType& screen) { props.drawChevron = rowShowsNavigationCaret(rows[i]); props.labelText = screen.theme().bodyText; props.valueText = screen.theme().smallText; + if (buttonDevice) props.minTouchSize = rowHeight; + setDrawerSelectionStyle(props.styles); props.state = isReaderDrawerRowFocused(buttonFocusActive, state.selectedIndex, static_cast(i)) ? fui::StateSelected : fui::StateNormal; - screen.settingRow(props); + screen.settingRow(props, rowHeight); } } -void EpubReaderTouchMenuActivity::buildSpacingPane(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildSpacingPane(UiApp::ScreenType& screen) { buildPaneHeader(screen); char lineValue[16]; std::snprintf(lineValue, sizeof(lineValue), "%u%%", draft.lineHeightPercent); @@ -954,10 +1123,14 @@ void EpubReaderTouchMenuActivity::buildSpacingPane(UiApp::ScreenType& screen) { configureReaderSliderScale(word, "0", "4"); line.captionGap = COMPACT_SLIDER_CAPTION_GAP; word.captionGap = COMPACT_SLIDER_CAPTION_GAP; - drawDualReaderSliderRows(screen, line, word); + line.focused = !mappedInput.hasTouchHardware() && buttonSliderState.focus == 0; + word.focused = !mappedInput.hasTouchHardware() && buttonSliderState.focus == 1; + line.editing = line.focused && buttonSliderState.editing; + word.editing = word.focused && buttonSliderState.editing; + drawDualReaderSliderRows(screen, line, word, !mappedInput.hasTouchHardware()); } -void EpubReaderTouchMenuActivity::buildMarginsPane(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildMarginsPane(UiApp::ScreenType& screen) { buildPaneHeader(screen); char verticalValue[16]; char horizontalValue[16]; @@ -985,23 +1158,65 @@ void EpubReaderTouchMenuActivity::buildMarginsPane(UiApp::ScreenType& screen) { configureReaderSliderScale(horizontal, "5", "150"); vertical.captionGap = COMPACT_SLIDER_CAPTION_GAP; horizontal.captionGap = COMPACT_SLIDER_CAPTION_GAP; - drawDualReaderSliderRows(screen, vertical, horizontal); + vertical.focused = !mappedInput.hasTouchHardware() && buttonSliderState.focus == 0; + horizontal.focused = !mappedInput.hasTouchHardware() && buttonSliderState.focus == 1; + vertical.editing = vertical.focused && buttonSliderState.editing; + horizontal.editing = horizontal.focused && buttonSliderState.editing; + drawDualReaderSliderRows(screen, vertical, horizontal, !mappedInput.hasTouchHardware()); } -void EpubReaderTouchMenuActivity::buildPercentPane(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildPercentPane(UiApp::ScreenType& screen) { buildPaneHeader(screen); + if (!mappedInput.hasTouchHardware() && !percentKeypadActive) { + buildPercentSlider(screen); + return; + } char value[16]; // Nothing typed yet: show the actual value the OK key would use (the position the // pane opened with), not a placeholder that would misrepresent what confirming does. if (entryLen > 0) { std::snprintf(value, sizeof(value), "%s%%", entryText); + } else if (percentKeypadActive) { + std::snprintf(value, sizeof(value), "0%%"); } else { std::snprintf(value, sizeof(value), "%d.%02d%%", percent / 100, percent % 100); } buildDrawerKeypad(screen, /*allowDecimal=*/true, value); } -void EpubReaderTouchMenuActivity::buildStablePagePane(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildPercentSlider(UiApp::ScreenType& screen) { + const auto& theme = screen.theme(); + fui::TextStyle readout = theme.titleText; + readout.align = fui::TextAlign::Center; + const int16_t readoutHeight = screen.target().lineHeight(readout.font); + const int16_t hintHeight = screen.target().lineHeight(theme.smallText.font); + const int16_t groupHeight = + static_cast(readoutHeight + theme.rowHeight + hintHeight * 3 + theme.spaceLg * 2 + theme.spaceSm * 2); + screen.spacer(std::max(0, static_cast((screen.body().height - groupHeight) / 2))); + + char value[16]; + std::snprintf(value, sizeof(value), "%d.%02d%%", percent / 100, percent % 100); + screen.target().text(screen.takeTop(readoutHeight, theme.spaceLg), value, readout); + + // The slider is visual only on button devices. Physical buttons change the + // value directly, preserving the old selector's 1% and 10% steps. + const fui::Insets sideInset{0, static_cast(theme.spaceLg * 2), 0, static_cast(theme.spaceLg * 2)}; + const fui::Rect row = screen.takeTop(theme.rowHeight, theme.spaceLg).inset(sideInset); + fui::SliderProps slider; + slider.value = percent / 10; + slider.max = 1000; + fui::slider(screen.frame(), row, slider); + + drawButtonSliderStepHints(screen, 1, 10, "%"); + screen.spacer(theme.spaceSm); + char keyboardHint[64]; + std::snprintf(keyboardHint, sizeof(keyboardHint), I18N.get(StrId::STR_HOLD_FOR_KEYBOARD), tr(STR_SELECT)); + fui::TextStyle hint = theme.smallText; + hint.align = fui::TextAlign::Center; + screen.target().text(screen.takeTop(hintHeight), keyboardHint, hint); +} + +void EpubReaderDrawerActivity::buildStablePagePane(UiApp::ScreenType& screen) { buildPaneHeader(screen); char value[32]; if (entryLen > 0) { @@ -1018,9 +1233,9 @@ void EpubReaderTouchMenuActivity::buildStablePagePane(UiApp::ScreenType& screen) // every other pane) - the drawer sheet's height budget only has room for the grid // itself once the header and readout are accounted for, so OK lives in the grid // instead, matching EpubReaderPercentSelectionActivity's non-touch keypad. The -// pane's existing hardware-Confirm handling in loop() still works unchanged. -void EpubReaderTouchMenuActivity::buildDrawerKeypad(UiApp::ScreenType& screen, const bool allowDecimal, - const char* value) { +// button device moves focus through the grid and uses Confirm on the selected key. +void EpubReaderDrawerActivity::buildDrawerKeypad(UiApp::ScreenType& screen, const bool allowDecimal, + const char* value) { const auto& theme = screen.theme(); fui::TextStyle readout = theme.titleText; readout.align = fui::TextAlign::Center; @@ -1034,11 +1249,13 @@ void EpubReaderTouchMenuActivity::buildDrawerKeypad(UiApp::ScreenType& screen, c // in the full row instead of shifting it left when the icon appears. screen.target().text(readoutRow, value, readout); - if (entryLen > 0) { + if (entryLen > 0 || !mappedInput.hasTouchHardware()) { fui::ButtonProps backspaceBtn; backspaceBtn.icon = fui::bitmapFromIcon(icon_backspace_28); backspaceBtn.action = ACTION_KEYPAD_BACKSPACE; - backspaceBtn.inputMask = fui::InputTouch; + backspaceBtn.inputMask = mappedInput.hasTouchHardware() ? fui::InputTouch : fui::InputNone; + backspaceBtn.state = keypadBackspaceFocused ? fui::StateSelected : fui::StateNormal; + backspaceBtn.enabled = entryLen > 0; screen.button(backspaceBtn, iconRect); } @@ -1064,6 +1281,8 @@ void EpubReaderTouchMenuActivity::buildDrawerKeypad(UiApp::ScreenType& screen, c gridProps.cols = 3; gridProps.action = ACTION_KEYPAD_KEY; gridProps.inputMask = fui::InputTouch; + if (!mappedInput.hasTouchHardware() && !keypadBackspaceFocused) + gridProps.selectedIndex = static_cast(keypadRow * 3 + keypadCol); gridProps.labelText = theme.bodyText; gridProps.labelText.align = fui::TextAlign::Center; gridProps.gap = theme.spaceSm; @@ -1080,9 +1299,10 @@ void EpubReaderTouchMenuActivity::buildDrawerKeypad(UiApp::ScreenType& screen, c fui::keyGrid(screen.frame(), gridArea, gridProps); } -void EpubReaderTouchMenuActivity::buildAutoPageTurnPane(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildAutoPageTurnPane(UiApp::ScreenType& screen) { buildPaneHeader(screen); - buildConfirmButton(screen); + const bool buttonDevice = !mappedInput.hasTouchHardware(); + if (!buttonDevice) buildConfirmButton(screen); char value[16]; std::snprintf(value, sizeof(value), "%us", autoPageTurnIntervalSeconds); ReaderSliderRowProps slider; @@ -1094,15 +1314,26 @@ void EpubReaderTouchMenuActivity::buildAutoPageTurnPane(UiApp::ScreenType& scree slider.decrement = ACTION_STEP; slider.increment = ACTION_STEP; configureReaderSliderScale(slider, "5s", "120s"); - const int16_t top = centeredReaderSliderControlTop(screen, slider); + const int16_t hintLineHeight = buttonDevice ? screen.target().lineHeight(screen.theme().smallText.font) : 0; + const int16_t hintsHeight = + buttonDevice ? static_cast(hintLineHeight * 2 + screen.theme().spaceMd + screen.theme().spaceSm) : 0; + const int16_t top = + buttonDevice + ? std::max(0, static_cast( + (screen.body().height - readerSliderRowHeight(screen, slider) - hintsHeight) / 2)) + : centeredReaderSliderControlTop(screen, slider); screen.spacer(top); drawReaderSliderRow(screen, slider); + if (buttonDevice) { + screen.spacer(screen.theme().spaceMd); + drawButtonSliderStepHints(screen, 1, 5, ""); + } } -void EpubReaderTouchMenuActivity::buildConfirmButton(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildConfirmButton(UiApp::ScreenType& screen) { const int16_t buttonHeight = std::max(screen.theme().minTouchSize, screen.theme().rowHeight); // `takeBottom(..., gap)` creates space above a control. Reserve this band - // first so Confirm also clears the navigation tabs below it. + // first so Confirm clears the navigation tabs on touch devices. screen.spacer(screen.theme().spaceLg, fui::LayoutAnchor::Bottom); const fui::Rect rect = screen.takeBottom(buttonHeight, screen.theme().spaceLg); fui::ButtonProps confirm; @@ -1117,7 +1348,7 @@ void EpubReaderTouchMenuActivity::buildConfirmButton(UiApp::ScreenType& screen) screen.button(confirm, rect); } -void EpubReaderTouchMenuActivity::buildDictionaryPane(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildDictionaryPane(UiApp::ScreenType& screen) { buildPaneHeader(screen); const int total = static_cast(dictionaryLabels.size()); fui::ListProps props; @@ -1141,7 +1372,7 @@ void EpubReaderTouchMenuActivity::buildDictionaryPane(UiApp::ScreenType& screen) screen.theme().listScrollInset); } -void EpubReaderTouchMenuActivity::buildFontFamilyPane(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildFontFamilyPane(UiApp::ScreenType& screen) { buildPaneHeader(screen); const int total = static_cast(fontLabels.size()); const fui::Rect listBounds = screen.body(); @@ -1149,13 +1380,16 @@ void EpubReaderTouchMenuActivity::buildFontFamilyPane(UiApp::ScreenType& screen) // This picker has no subtitles, so it does not need the default // label-plus-subtitle row height that left a conspicuous blank band above // the tab row when scrolling. - props.rowHeight = std::max( - screen.theme().minTouchSize, static_cast(screen.theme().rowHeight - FONT_FAMILY_ROW_HEIGHT_REDUCTION)); + props.rowHeight = + !mappedInput.hasTouchHardware() + ? uiListRowHeight(screen.theme(), UiListRowType::SingleLine) + : std::max(screen.theme().minTouchSize, + static_cast(screen.theme().rowHeight - FONT_FAMILY_ROW_HEIGHT_REDUCTION)); visibleRows = configureDrawerList(props, screen.theme(), listBounds); const int top = std::clamp(state.paneTopIndex, 0, std::max(0, total - visibleRows)); state.paneTopIndex = static_cast(top); const int drawCount = std::min({visibleRows, WINDOW_SIZE, total - top}); - evenlySpaceDrawerListRows(props, listBounds, drawCount); + if (!CROSSINK_APP_READER_SAMPLE_PREVIEW) evenlySpaceDrawerListRows(props, listBounds, drawCount); int selectedFontIndex = state.pendingFontIndex; if (selectedFontIndex < 0) { if (draft.sdFontFamilyName[0] != '\0') { @@ -1191,7 +1425,7 @@ void EpubReaderTouchMenuActivity::buildFontFamilyPane(UiApp::ScreenType& screen) screen.theme().listScrollInset); } -void EpubReaderTouchMenuActivity::buildEnumOptionsPane(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildEnumOptionsPane(UiApp::ScreenType& screen) { buildPaneHeader(screen); const int total = static_cast(enumOptionLabels.size()); const fui::Rect listBounds = screen.body(); @@ -1200,8 +1434,9 @@ void EpubReaderTouchMenuActivity::buildEnumOptionsPane(UiApp::ScreenType& screen const int top = std::clamp(state.paneTopIndex, 0, std::max(0, total - visibleRows)); state.paneTopIndex = static_cast(top); const int drawCount = std::min({visibleRows, WINDOW_SIZE, total - top}); - if (enumOptionRow == RowId::FontSize || enumOptionRow == RowId::DictionaryFontFamily || - enumOptionRow == RowId::DictionaryFontSize) { + if (!CROSSINK_APP_READER_SAMPLE_PREVIEW && + (enumOptionRow == RowId::FontSize || enumOptionRow == RowId::DictionaryFontFamily || + enumOptionRow == RowId::DictionaryFontSize)) { evenlySpaceDrawerListRows(props, listBounds, drawCount); } const int selectedIndex = previewedEnumOptionIndex >= 0 ? previewedEnumOptionIndex : enumOptionSelectedIndex; @@ -1223,7 +1458,7 @@ void EpubReaderTouchMenuActivity::buildEnumOptionsPane(UiApp::ScreenType& screen } #if CROSSINK_SCALABLE_FONTS -void EpubReaderTouchMenuActivity::buildTtfRenderingPane(UiApp::ScreenType& screen) { +void EpubReaderDrawerActivity::buildTtfRenderingPane(UiApp::ScreenType& screen) { buildPaneHeader(screen); const auto& rows = activeRows(); const int total = static_cast(rows.size()); @@ -1259,11 +1494,19 @@ void EpubReaderTouchMenuActivity::buildTtfRenderingPane(UiApp::ScreenType& scree } #endif -void EpubReaderTouchMenuActivity::openPane(const ReaderDrawerPane pane) { +void EpubReaderDrawerActivity::openPane(const ReaderDrawerPane pane) { state.pane = pane; state.paneTopIndex = 0; state.selectedIndex = 0; - if (pane == ReaderDrawerPane::Percent || pane == ReaderDrawerPane::StablePage) resetKeypadEntry(); + buttonSliderState = {}; + if (pane == ReaderDrawerPane::Percent || pane == ReaderDrawerPane::StablePage) { + percentKeypadActive = false; + percentConfirmLongPressFired = false; + keypadRow = 0; + keypadCol = 0; + keypadBackspaceFocused = false; + resetKeypadEntry(); + } paneRows.clear(); if (pane == ReaderDrawerPane::ReaderFont) { paneRows = {RowId::FontFamily, RowId::FontSize}; @@ -1283,7 +1526,9 @@ void EpubReaderTouchMenuActivity::openPane(const ReaderDrawerPane pane) { requestUpdate(); } -void EpubReaderTouchMenuActivity::closePane() { +void EpubReaderDrawerActivity::closePane() { + buttonSliderState.editing = false; + percentKeypadActive = false; if (state.pane == ReaderDrawerPane::Root) { closeAndReturn(true); return; @@ -1313,20 +1558,23 @@ void EpubReaderTouchMenuActivity::closePane() { #endif state.paneTopIndex = 0; state.selectedIndex = 0; + if (!mappedInput.hasTouchHardware() && state.pane == ReaderDrawerPane::Root) buttonFocusActive = false; requestUpdate(); } -void EpubReaderTouchMenuActivity::changeTab(const ReaderDrawerTab tab) { +void EpubReaderDrawerActivity::changeTab(const ReaderDrawerTab tab) { #if CROSSINK_SCALABLE_FONTS finishTtfRenderingEdit(); #endif state.tab = tab; state.pane = ReaderDrawerPane::Root; + percentKeypadActive = false; state.selectedIndex = 0; + if (!mappedInput.hasTouchHardware()) buttonFocusActive = false; requestUpdate(); } -void EpubReaderTouchMenuActivity::activateListIndex(const int index) { +void EpubReaderDrawerActivity::activateListIndex(const int index) { if (state.pane == ReaderDrawerPane::EnumOptions) { selectEnumOption(index); return; @@ -1379,7 +1627,7 @@ void EpubReaderTouchMenuActivity::activateListIndex(const int index) { activateRow(rows[static_cast(index)]); } -void EpubReaderTouchMenuActivity::activateRow(const RowId row) { +void EpubReaderDrawerActivity::activateRow(const RowId row) { switch (row) { case RowId::ReaderFont: openPane(ReaderDrawerPane::ReaderFont); @@ -1435,12 +1683,16 @@ void EpubReaderTouchMenuActivity::activateRow(const RowId row) { openPane(ReaderDrawerPane::Margins); return; case RowId::SelectChapter: - closeAndReturn(false, EpubReaderMenuAction::SELECT_CHAPTER, false); + closeAndReturn(false, EpubReaderMenuAction::SELECT_CHAPTER, !mappedInput.hasTouchHardware()); return; case RowId::GoToPercent: openPane(ReaderDrawerPane::Percent); return; case RowId::GoToStablePage: + if (!mappedInput.hasTouchHardware()) { + closeAndReturn(false, EpubReaderMenuAction::GO_TO_STABLE_PAGE, true); + return; + } openPane(ReaderDrawerPane::StablePage); return; case RowId::AutoPageTurn: @@ -1453,6 +1705,10 @@ void EpubReaderTouchMenuActivity::activateRow(const RowId row) { openPane(ReaderDrawerPane::DictionaryFont); return; case RowId::StatusBar: + if (!mappedInput.hasTouchHardware()) { + closeAndReturn(false, EpubReaderMenuAction::STATUS_BAR_SETTINGS, true); + return; + } commitSettings(); if (auto statusBar = makeUniqueNoThrow(renderer, mappedInput, true, stablePageCount > 0)) { @@ -1465,6 +1721,10 @@ void EpubReaderTouchMenuActivity::activateRow(const RowId row) { } return; case RowId::Controls: + if (!mappedInput.hasTouchHardware()) { + closeAndReturn(false, EpubReaderMenuAction::CONTROLS_OPTIONS, true); + return; + } commitSettings(); if (auto controls = makeUniqueNoThrow(renderer, mappedInput)) { if (beginGlobalSettingsEditCallback) beginGlobalSettingsEditCallback(beginGlobalSettingsEditContext); @@ -1489,7 +1749,7 @@ void EpubReaderTouchMenuActivity::activateRow(const RowId row) { return; case RowId::BookmarkToggle: isCurrentPageBookmarked = !isCurrentPageBookmarked; - closeAndReturn(false, EpubReaderMenuAction::BOOKMARK_TOGGLE, false); + closeAndReturn(false, EpubReaderMenuAction::BOOKMARK_TOGGLE, !mappedInput.hasTouchHardware()); return; case RowId::ToggleCompleted: isBookCompleted = !isBookCompleted; @@ -1510,6 +1770,18 @@ void EpubReaderTouchMenuActivity::activateRow(const RowId row) { EpubReaderMenuAction action = EpubReaderMenuAction::GO_HOME; switch (row) { + case RowId::ReadingStats: + action = EpubReaderMenuAction::READING_STATS; + break; + case RowId::SyncProgress: + action = EpubReaderMenuAction::SYNC; + break; + case RowId::NearbyPositionSync: + action = EpubReaderMenuAction::NEARBY_POSITION_SYNC; + break; + case RowId::SendNearbyBook: + action = EpubReaderMenuAction::SEND_NEARBY_BOOK; + break; case RowId::ViewBookmarks: action = EpubReaderMenuAction::VIEW_BOOKMARKS; break; @@ -1540,10 +1812,10 @@ void EpubReaderTouchMenuActivity::activateRow(const RowId row) { default: return; } - closeAndReturn(false, action, false); + closeAndReturn(false, action, !mappedInput.hasTouchHardware()); } -void EpubReaderTouchMenuActivity::toggleSetting(const RowId row) { +void EpubReaderDrawerActivity::toggleSetting(const RowId row) { switch (row) { case RowId::TextAa: draft.textAntiAliasing = !draft.textAntiAliasing; @@ -1584,7 +1856,7 @@ void EpubReaderTouchMenuActivity::toggleSetting(const RowId row) { requestUpdate(); } -void EpubReaderTouchMenuActivity::showEnumOptions(const RowId row) { +void EpubReaderDrawerActivity::showEnumOptions(const RowId row) { RenderLock lock; const bool fontSizeNeedsLoading = row == RowId::FontSize && draft.sdFontFamilyName[0] != '\0' && ReaderUtils::shouldShowFontPreviewLoading(draft.sdFontFamilyName.data()); @@ -1696,8 +1968,8 @@ void EpubReaderTouchMenuActivity::showEnumOptions(const RowId row) { openEnumOptions(row, title, std::move(labels), std::move(raw), current); } -void EpubReaderTouchMenuActivity::openEnumOptions(const RowId row, const StrId title, std::vector labels, - std::vector values, const int selectedIndex) { +void EpubReaderDrawerActivity::openEnumOptions(const RowId row, const StrId title, std::vector labels, + std::vector values, const int selectedIndex) { if (labels.empty() || labels.size() != values.size()) return; enumOptionRow = row; enumOptionTitle = title; @@ -1713,7 +1985,7 @@ void EpubReaderTouchMenuActivity::openEnumOptions(const RowId row, const StrId t requestUpdate(); } -void EpubReaderTouchMenuActivity::notifyDictionaryFontChanged() { +void EpubReaderDrawerActivity::notifyDictionaryFontChanged() { if (dictionaryFontChangedCallback) { dictionaryFontChangedCallback(dictionaryFontChangedContext, hasDictionaryFontOverride ? dictionaryFontFamilyName : nullptr, @@ -1721,7 +1993,7 @@ void EpubReaderTouchMenuActivity::notifyDictionaryFontChanged() { } } -void EpubReaderTouchMenuActivity::selectEnumOption(const int index) { +void EpubReaderDrawerActivity::selectEnumOption(const int index) { if (index < 0 || index >= static_cast(enumOptionValues.size())) return; const uint8_t value = enumOptionValues[static_cast(index)]; const bool previewsBeforeSelecting = enumOptionRow == RowId::FontSize || enumOptionRow == RowId::Alignment; @@ -1822,7 +2094,7 @@ void EpubReaderTouchMenuActivity::selectEnumOption(const int index) { requestUpdate(); } -void EpubReaderTouchMenuActivity::resetKeypadEntry() { +void EpubReaderDrawerActivity::resetKeypadEntry() { entryLen = 0; entryText[0] = 0; // Discard anything typed on a previous visit to this pane along with the buffer, @@ -1833,7 +2105,76 @@ void EpubReaderTouchMenuActivity::resetKeypadEntry() { requestUpdate(); } -void EpubReaderTouchMenuActivity::appendKeypadDigit(const char digit) { +void EpubReaderDrawerActivity::enterPercentKeypad() { + percentBeforeKeypad = percent; + if (percent % 100 == 0) { + std::snprintf(entryText, sizeof(entryText), "%d", percent / 100); + } else if (percent % 10 == 0) { + std::snprintf(entryText, sizeof(entryText), "%d.%d", percent / 100, (percent % 100) / 10); + } else { + std::snprintf(entryText, sizeof(entryText), "%d.%02d", percent / 100, percent % 100); + } + entryLen = static_cast(std::strlen(entryText)); + keypadRow = 0; + keypadCol = 0; + keypadBackspaceFocused = false; + percentKeypadActive = true; + requestUpdate(); +} + +void EpubReaderDrawerActivity::exitPercentKeypad() { + percent = percentBeforeKeypad; + percentKeypadActive = false; + entryLen = 0; + entryText[0] = 0; + requestUpdate(); +} + +void EpubReaderDrawerActivity::adjustPercentSlider(const int steps) { + const int raw = percent + steps * 100; + // Keep 100% as a reachable endpoint while preserving the old wrap behavior. + percent = raw > 0 && raw % 10000 == 0 ? 10000 : ((raw % 10000) + 10000) % 10000; + requestUpdate(); +} + +void EpubReaderDrawerActivity::moveKeypadFocus(const int rowDelta, const int colDelta) { + if (keypadBackspaceFocused) { + if (rowDelta > 0 || colDelta != 0) keypadBackspaceFocused = false; + } else if (rowDelta < 0 && keypadRow == 0 && entryLen > 0) { + keypadBackspaceFocused = true; + } else { + keypadRow = static_cast((keypadRow + rowDelta + 4) % 4); + keypadCol = static_cast((keypadCol + colDelta + 3) % 3); + if (state.pane == ReaderDrawerPane::StablePage && keypadRow == 3 && keypadCol == 1) { + if (colDelta != 0) + keypadCol = static_cast((keypadCol + colDelta + 3) % 3); + else + keypadRow = static_cast((keypadRow + rowDelta + 4) % 4); + } + } + requestUpdate(); +} + +void EpubReaderDrawerActivity::activateKeypadFocus() { + if (keypadBackspaceFocused) { + backspaceKeypadEntry(); + return; + } + const int index = keypadRow * 3 + keypadCol; + if (index == 11) { + if (percentKeypadActive && entryLen == 0) return; + if (state.pane == ReaderDrawerPane::Percent) + completePercentSelection(); + else + completeStablePageSelection(); + } else if (index == 10) { + appendKeypadDecimalPoint(); + } else { + appendKeypadDigit(static_cast('0' + (index == 9 ? 0 : index + 1))); + } +} + +void EpubReaderDrawerActivity::appendKeypadDigit(const char digit) { if (entryLen >= sizeof(entryText) - 1) return; // Reject a digit that would push the typed value out of range, rather than let the // readout show a number Confirm would silently clamp to something else. @@ -1859,7 +2200,7 @@ void EpubReaderTouchMenuActivity::appendKeypadDigit(const char digit) { requestUpdate(); } -void EpubReaderTouchMenuActivity::appendKeypadDecimalPoint() { +void EpubReaderDrawerActivity::appendKeypadDecimalPoint() { if (state.pane != ReaderDrawerPane::Percent) return; // Require a leading digit ("0." rather than bare "."): otherwise the readout // would show "." while OK silently confirms 0%. @@ -1874,13 +2215,14 @@ void EpubReaderTouchMenuActivity::appendKeypadDecimalPoint() { requestUpdate(); } -void EpubReaderTouchMenuActivity::backspaceKeypadEntry() { +void EpubReaderDrawerActivity::backspaceKeypadEntry() { if (entryLen == 0) return; entryText[--entryLen] = 0; if (entryLen == 0) { + keypadBackspaceFocused = false; // Nothing left to parse: go back to the book's actual position rather than // leaving the last successfully-parsed (now abandoned) value in place. - percent = percentSeed; + percent = percentKeypadActive ? percentBeforeKeypad : percentSeed; stablePage = stablePageSeed; } else { syncKeypadValue(); @@ -1888,7 +2230,7 @@ void EpubReaderTouchMenuActivity::backspaceKeypadEntry() { requestUpdate(); } -void EpubReaderTouchMenuActivity::syncKeypadValue() { +void EpubReaderDrawerActivity::syncKeypadValue() { if (entryLen == 0) return; if (state.pane == ReaderDrawerPane::StablePage) { stablePage = clampStablePage(static_cast(std::strtoul(entryText, nullptr, 10)), stablePageCount); @@ -1898,8 +2240,8 @@ void EpubReaderTouchMenuActivity::syncKeypadValue() { } } -void EpubReaderTouchMenuActivity::onKeypadKeyEvent(const fui::ActionEvent& event, void* user) { - auto* self = static_cast(user); +void EpubReaderDrawerActivity::onKeypadKeyEvent(const fui::ActionEvent& event, void* user) { + auto* self = static_cast(user); self->buttonFocusActive = false; const int16_t value = event.value; if (value == KEYPAD_OK) { @@ -1915,13 +2257,13 @@ void EpubReaderTouchMenuActivity::onKeypadKeyEvent(const fui::ActionEvent& event } } -void EpubReaderTouchMenuActivity::onKeypadBackspaceEvent(const fui::ActionEvent&, void* user) { - auto* self = static_cast(user); +void EpubReaderDrawerActivity::onKeypadBackspaceEvent(const fui::ActionEvent&, void* user) { + auto* self = static_cast(user); self->buttonFocusActive = false; self->backspaceKeypadEntry(); } -void EpubReaderTouchMenuActivity::completePercentSelection() { +void EpubReaderDrawerActivity::completePercentSelection() { const bool changed = didChangeSettings; commitSettings(); MenuResult menu{static_cast(EpubReaderMenuAction::GO_TO_PERCENT), draft.orientation, changed}; @@ -1933,7 +2275,7 @@ void EpubReaderTouchMenuActivity::completePercentSelection() { finish(); } -void EpubReaderTouchMenuActivity::completeStablePageSelection() { +void EpubReaderDrawerActivity::completeStablePageSelection() { const bool changed = didChangeSettings; commitSettings(); MenuResult menu{static_cast(EpubReaderMenuAction::GO_TO_STABLE_PAGE), draft.orientation, changed}; @@ -1944,7 +2286,7 @@ void EpubReaderTouchMenuActivity::completeStablePageSelection() { finish(); } -void EpubReaderTouchMenuActivity::completeAutoPageTurnSelection() { +void EpubReaderDrawerActivity::completeAutoPageTurnSelection() { const bool changed = didChangeSettings; commitSettings(); MenuResult menu{static_cast(EpubReaderMenuAction::AUTO_PAGE_TURN), draft.orientation, changed}; @@ -1955,7 +2297,7 @@ void EpubReaderTouchMenuActivity::completeAutoPageTurnSelection() { finish(); } -void EpubReaderTouchMenuActivity::adjustActiveSlider(const int delta) { +void EpubReaderDrawerActivity::adjustActiveSlider(const int delta) { if (state.pane == ReaderDrawerPane::Spacing) { draft.lineHeightPercent = CrossPointSettings::clampedLineHeightPercent(static_cast( std::clamp(draft.lineHeightPercent + delta, CrossPointSettings::MIN_LINE_HEIGHT_PERCENT, @@ -1970,8 +2312,35 @@ void EpubReaderTouchMenuActivity::adjustActiveSlider(const int delta) { } } -void EpubReaderTouchMenuActivity::moveSelection(const bool forward, const bool page) { - buttonFocusActive = true; +void EpubReaderDrawerActivity::adjustButtonSlider(const int delta) { + bool changed = false; + if (state.pane == ReaderDrawerPane::Spacing) { + if (buttonSliderState.focus == 0) { + const uint8_t value = CrossPointSettings::clampedLineHeightPercent(static_cast( + std::clamp(draft.lineHeightPercent + delta, CrossPointSettings::MIN_LINE_HEIGHT_PERCENT, + CrossPointSettings::MAX_LINE_HEIGHT_PERCENT))); + changed = value != draft.lineHeightPercent; + draft.lineHeightPercent = value; + } else { + const uint8_t value = + static_cast(std::clamp(draft.wordSpacing + delta, 0, CrossPointSettings::MAX_WORD_SPACING)); + changed = value != draft.wordSpacing; + draft.wordSpacing = value; + } + } else if (state.pane == ReaderDrawerPane::Margins) { + uint8_t& target = buttonSliderState.focus == 0 ? draft.screenMarginVertical : draft.screenMarginHorizontal; + const uint8_t value = static_cast( + std::clamp(target + delta, CrossPointSettings::MIN_SCREEN_MARGIN, CrossPointSettings::MAX_SCREEN_MARGIN)); + changed = value != target; + target = value; + } else if (state.pane == ReaderDrawerPane::AutoPageTurn) { + autoPageTurnIntervalSeconds = static_cast(std::clamp( + autoPageTurnIntervalSeconds + delta, READER_AUTO_PAGE_TURN_MIN_SECONDS, READER_AUTO_PAGE_TURN_MAX_SECONDS)); + } + if (changed) markSettingChanged(ReaderSettingsChangeMask::Preview | ReaderSettingsChangeMask::Relayout); +} + +void EpubReaderDrawerActivity::moveSelection(const bool forward, const bool page) { int count = 0; if (state.pane == ReaderDrawerPane::EnumOptions) { count = static_cast(enumOptionLabels.size()); @@ -1983,10 +2352,15 @@ void EpubReaderTouchMenuActivity::moveSelection(const bool forward, const bool p count = static_cast(activeRows().size()); } if (count <= 0) return; - state.selectedIndex = page ? (forward ? ButtonNavigator::nextPageIndex(state.selectedIndex, count, visibleRows) - : ButtonNavigator::previousPageIndex(state.selectedIndex, count, visibleRows)) - : (forward ? ButtonNavigator::nextIndex(state.selectedIndex, count) - : ButtonNavigator::previousIndex(state.selectedIndex, count)); + if (!mappedInput.hasTouchHardware() && state.pane == ReaderDrawerPane::Root && !buttonFocusActive) { + state.selectedIndex = forward ? 0 : static_cast(count - 1); + } else { + state.selectedIndex = page ? (forward ? ButtonNavigator::nextPageIndex(state.selectedIndex, count, visibleRows) + : ButtonNavigator::previousPageIndex(state.selectedIndex, count, visibleRows)) + : (forward ? ButtonNavigator::nextIndex(state.selectedIndex, count) + : ButtonNavigator::previousIndex(state.selectedIndex, count)); + } + buttonFocusActive = true; const int top = followListSelection(state.selectedIndex, activeTopIndex(), visibleRows, count); if (state.pane == ReaderDrawerPane::Root) { state.rootTopIndex[static_cast(state.tab)] = static_cast(top); @@ -1996,7 +2370,7 @@ void EpubReaderTouchMenuActivity::moveSelection(const bool forward, const bool p requestUpdate(); } -void EpubReaderTouchMenuActivity::scrollBy(const int delta) { +void EpubReaderDrawerActivity::scrollBy(const int delta) { buttonFocusActive = false; int count = 0; if (state.pane == ReaderDrawerPane::EnumOptions) @@ -2017,7 +2391,7 @@ void EpubReaderTouchMenuActivity::scrollBy(const int delta) { requestUpdate(); } -bool EpubReaderTouchMenuActivity::saveBookDictionary(const std::string& path) { +bool EpubReaderDrawerActivity::saveBookDictionary(const std::string& path) { if (!epub) return false; HalFile file; const std::string target = epub->getCachePath() + "/dictionary.bin"; @@ -2032,17 +2406,41 @@ bool EpubReaderTouchMenuActivity::saveBookDictionary(const std::string& path) { return ok; } -void EpubReaderTouchMenuActivity::renderPreviewContents(const ReaderSettingsDraft& previewSettings, - const int previewFontId) { - const int previewTop = 0; - const int previewBottom = renderer.getScreenHeight() - drawerHeight(); - const int previewHeight = std::max(0, previewBottom - previewTop); - renderer.fillRect(0, previewTop, renderer.getScreenWidth(), previewHeight, ReaderUtils::readerDarkModeEnabled()); +void EpubReaderDrawerActivity::renderPreviewContents(const ReaderSettingsDraft& previewSettings, + const int previewFontId) { + const fui::Rect preview = previewBounds(); + renderer.fillRect(preview.x, preview.y, preview.width, preview.height, ReaderUtils::readerDarkModeEnabled()); renderPreviewText(previewSettings, previewFontId); + if (CROSSINK_APP_READER_SAMPLE_PREVIEW) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const int labelTextHeight = renderer.getTextHeight(UI_10_FONT_ID); + const char* name = previewSettings.sdFontFamilyName[0] + ? previewSettings.sdFontFamilyName.data() + : (previewSettings.fontFamily == 0 ? tr(STR_LEXEND_DECA) : tr(STR_BITTER)); + char label[128]; + std::snprintf(label, sizeof(label), "%s \"%s\", %upt", tr(STR_PREVIEW), name, previewSettings.readerFontPointSize); + const int separatorY = preview.bottom() - metrics.previewPadding - labelTextHeight - 4; + renderer.drawLine(preview.x, separatorY, preview.right() - 1, separatorY, ReaderUtils::readerForegroundBlack()); + renderer.beginTextClip(preview.x, preview.y, preview.width, preview.height); + renderer.drawText(UI_10_FONT_ID, preview.x + metrics.previewPadding, + preview.bottom() - metrics.previewPadding - labelTextHeight, label, + ReaderUtils::readerForegroundBlack()); + renderer.endTextClip(); + renderer.drawLine(preview.x, preview.bottom() - 1, preview.right() - 1, preview.bottom() - 1, + ReaderUtils::readerForegroundBlack()); + } } -void EpubReaderTouchMenuActivity::renderPreviewText(const ReaderSettingsDraft& previewSettings, - const int previewFontId) { +void EpubReaderDrawerActivity::renderPreviewText(const ReaderSettingsDraft& previewSettings, const int previewFontId) { + if (CROSSINK_APP_READER_SAMPLE_PREVIEW) { + renderSamplePreviewText(previewSettings, previewFontId); + return; + } + const fui::Rect preview = previewBounds(); + const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, false, false); + const int clipTop = std::max(preview.y, safe.y); + const int clipBottom = std::min(preview.bottom(), safe.y + safe.height); + const int clipHeight = std::max(0, clipBottom - clipTop); const bool sourceLayout = !previewFontMetricsChanged && previewSettings.fontFamily == sourceSettings.fontFamily && previewSettings.readerFontPointSize == sourceSettings.readerFontPointSize && previewSettings.sdFontFamilyName == sourceSettings.sdFontFamilyName && @@ -2053,8 +2451,8 @@ void EpubReaderTouchMenuActivity::renderPreviewText(const ReaderSettingsDraft& p previewSettings.paragraphAlignment == sourceSettings.paragraphAlignment && previewSettings.focusReadingEnabled == sourceSettings.focusReadingEnabled && previewSettings.guideReadingEnabled == sourceSettings.guideReadingEnabled; - if (sourceLayout) { - renderer.beginTextClip(0, 0, renderer.getScreenWidth(), renderer.getScreenHeight() - drawerHeight()); + if (sourceLayout && EpubReaderPreviewModel::RETAINS_SOURCE_BLOCKS) { + renderer.beginTextClip(safe.x, clipTop, safe.width, clipHeight); previewModel->renderSource(renderer, previewFontId, ReaderUtils::readerForegroundBlack()); renderer.endTextClip(); return; @@ -2064,13 +2462,13 @@ void EpubReaderTouchMenuActivity::renderPreviewText(const ReaderSettingsDraft& p (void)orientedRight; (void)orientedBottom; (void)orientedLeft; - const int clockReservation = ReaderUtils::getTopClockStatusBarReservedHeight(renderer); + const int clockReservation = ReaderUtils::getTopStatusBarReservedHeight(renderer); const int previewYOffset = orientedTop + std::max(static_cast(previewSettings.screenMarginVertical), - clockReservation > 0 ? clockReservation + ReaderUtils::TOP_CLOCK_TEXT_PADDING : 0); + clockReservation > 0 ? clockReservation + ReaderUtils::TOP_STATUS_BAR_TEXT_PADDING : 0); const int previewWidth = std::max(1, renderer.getScreenWidth() - static_cast(previewSettings.screenMarginHorizontal) * 2); - renderer.beginTextClip(0, 0, renderer.getScreenWidth(), renderer.getScreenHeight() - drawerHeight()); + renderer.beginTextClip(safe.x, clipTop, safe.width, clipHeight); previewModel->renderText(renderer, previewFontId, previewSettings.screenMarginHorizontal, previewYOffset, previewWidth, previewSettings.lineHeightPercent, previewSettings.wordSpacing, previewSettings.paragraphAlignment, previewSettings.focusReadingEnabled, @@ -2078,17 +2476,66 @@ void EpubReaderTouchMenuActivity::renderPreviewText(const ReaderSettingsDraft& p renderer.endTextClip(); } -bool EpubReaderTouchMenuActivity::renderPreview(int& previewFontId) { +void EpubReaderDrawerActivity::renderSamplePreviewText(const ReaderSettingsDraft& settings, const int fontId) { + if (!previewModel || !previewModel->valid()) return; + const auto& metrics = UITheme::getInstance().getMetrics(); + const fui::Rect area = previewBounds(); + const int labelHeight = renderer.getTextHeight(UI_10_FONT_ID) + metrics.previewPadding + 8; + const int textHeight = std::max(0, area.height - labelHeight - metrics.previewPadding); + // The sample is a short page: show top AND bottom margins proportionally + // to its height, while horizontal margins and font sizes remain actual pixels. + const int marginY = + settings.screenMarginVertical * textHeight / std::max(1, static_cast(renderer.getScreenHeight())); + const int marginX = settings.screenMarginHorizontal; + const int top = area.y + metrics.previewPadding + marginY; + const int width = std::max(1, area.width - marginX * 2); + renderer.beginTextClip(area.x + marginX, top, width, std::max(0, textHeight - marginY * 2)); + previewModel->renderText(renderer, fontId, area.x + marginX, top, width, settings.lineHeightPercent, + settings.wordSpacing, settings.paragraphAlignment, settings.focusReadingEnabled, + settings.guideReadingEnabled, ReaderUtils::readerForegroundBlack(), + top + std::max(0, textHeight - marginY * 2)); + renderer.endTextClip(); +} + +void EpubReaderDrawerActivity::renderPreviewUnavailable() { + const fui::Rect preview = previewBounds(); + renderer.fillRect(preview.x, preview.y, preview.width, preview.height, ReaderUtils::readerDarkModeEnabled()); + renderer.drawCenteredText(UI_12_FONT_ID, preview.y + preview.height / 2, tr(STR_PREVIEW_UNAVAILABLE), + ReaderUtils::readerForegroundBlack()); +} + +bool EpubReaderDrawerActivity::renderPreview(int& previewFontId, + std::optional& prewarmScope) { previewFontId = -1; + // cppcheck-suppress knownConditionTrueFalse ; compile-time constant on builds without the sample preview + if (CROSSINK_APP_READER_SAMPLE_PREVIEW && !showsSamplePreview()) return false; if (!previewDirty) return false; previewDirty = false; + const auto releasePreviewIfBelowReserve = [this, &previewFontId] { + if (!ownedPreviewModel) return false; + const auto heap = MemoryBudget::snapshot(); + if (MemoryBudget::hasHeap(heap, MemoryBudget::EPUB_TEXT_LAYOUT_MIN_FREE, + MemoryBudget::EPUB_TEXT_LAYOUT_MIN_MAX_ALLOC)) + return false; + LOG_ERR("ERDM", "Button preview exhausted EPUB layout reserve: free=%u maxAlloc=%u", heap.freeHeap, + heap.maxAllocHeap); + restoreReaderDraftFont(draft, lastGoodPreviewSettings); + previewUnavailable = true; + previewFontId = -1; + renderPreviewUnavailable(); + return true; + }; if (!previewModel || !previewModel->valid()) { + if (previewUnavailable) { + renderPreviewUnavailable(); + return false; + } if (!previewFontMetricsChanged) return false; - const int previewBottom = renderer.getScreenHeight() - drawerHeight(); - renderer.fillRect(0, 0, renderer.getScreenWidth(), std::max(0, previewBottom), - ReaderUtils::readerDarkModeEnabled()); + const fui::Rect preview = previewBounds(); + renderer.fillRect(preview.x, preview.y, preview.width, preview.height, ReaderUtils::readerDarkModeEnabled()); return true; } + if (CROSSINK_APP_READER_SAMPLE_PREVIEW && releasePreviewIfBelowReserve()) return false; if (fontPreviewLoading) { GUI.drawPopup(renderer, tr(STR_LOADING_POPUP), true); fontPreviewLoading = false; @@ -2098,18 +2545,40 @@ bool EpubReaderTouchMenuActivity::renderPreview(int& previewFontId) { applySettings(previewSettings); sdFontSystem.ensureLoaded(renderer); previewFontId = SETTINGS.getReaderFontId(); + const bool fontLoaded = + previewSettings.sdFontFamilyName[0] == '\0' || + sdFontSystem.resolveFontId(previewSettings.sdFontFamilyName.data(), previewSettings.readerFontPointSize) != 0; applySettings(liveSettings); + if (!fontLoaded && ownedPreviewModel) { + LOG_ERR("ERDM", "Could not load selected SD font for button preview"); + restoreReaderDraftFont(draft, lastGoodPreviewSettings); + previewUnavailable = true; + previewFontId = -1; + renderPreviewUnavailable(); + return false; + } if (auto* fontCacheManager = renderer.getFontCacheManager()) { - auto scope = fontCacheManager->createPrewarmScope(); + prewarmScope.emplace(*fontCacheManager, FontCacheManager::PreparationPolicy::Normal); renderPreviewContents(previewSettings, previewFontId); // Scan the page text before loading the selected font's glyphs. - scope.endScanAndPrewarm(); + if (!prewarmScope->endScanAndPrewarm() && ownedPreviewModel) { + LOG_ERR("ERDM", "Could not prewarm selected font for button preview"); + restoreReaderDraftFont(draft, lastGoodPreviewSettings); + previewUnavailable = true; + previewFontId = -1; + renderPreviewUnavailable(); + return false; + } + if (releasePreviewIfBelowReserve()) return false; } renderPreviewContents(previewSettings, previewFontId); + if (releasePreviewIfBelowReserve()) return false; + lastGoodPreviewSettings = draft; + previewUnavailable = false; return true; } -void EpubReaderTouchMenuActivity::renderPreviewWithAntiAliasing(const int previewFontId) { +void EpubReaderDrawerActivity::renderPreviewWithAntiAliasing(const int previewFontId) { if (!previewModel || !previewModel->valid()) return; const ReaderSettingsDraft previewSettings = draft; @@ -2119,12 +2588,13 @@ void EpubReaderTouchMenuActivity::renderPreviewWithAntiAliasing(const int previe renderer, [this, &previewSettings, previewFontId] { renderPreviewText(previewSettings, previewFontId); }); } -void EpubReaderTouchMenuActivity::loop() { +void EpubReaderDrawerActivity::loop() { if (mappedInput.wasBottomEdgeUpSwipe()) { closeAndReturn(false, EpubReaderMenuAction::GO_HOME, false); return; } - if (DrawerHandle::wasDismissSwipe(mappedInput, drawerHandleRect, fui::SheetEdge::Bottom)) { + if (DrawerHandle::wasDismissSwipe(mappedInput, drawerHandleRect, + mappedInput.hasTouchHardware() ? fui::SheetEdge::Bottom : fui::SheetEdge::Top)) { closeAndReturn(true); return; } @@ -2170,24 +2640,135 @@ void EpubReaderTouchMenuActivity::loop() { } } } + // Holding Confirm enters the Percent keypad (or deletes a digit there). + // Swallow that hold's release so it cannot also jump to a location. + if (!mappedInput.hasTouchHardware() && state.pane == ReaderDrawerPane::Percent && percentConfirmLongPressFired) { + if (!mappedInput.isPressed(MappedInputManager::Button::Confirm)) percentConfirmLongPressFired = false; + return; + } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + if (!mappedInput.hasTouchHardware() && state.pane == ReaderDrawerPane::Root && buttonFocusActive) { + buttonFocusActive = false; + requestUpdate(); + return; + } buttonFocusActive = true; + if (!mappedInput.hasTouchHardware() && state.pane == ReaderDrawerPane::Percent && percentKeypadActive) { + exitPercentKeypad(); + return; + } + if (!mappedInput.hasTouchHardware() && state.pane == ReaderDrawerPane::AutoPageTurn) { + closePane(); + return; + } + if (!mappedInput.hasTouchHardware() && readerDrawerStepChangesSettings(state.pane)) { + if (readerButtonSliderInput(buttonSliderState, ReaderButtonSliderInput::Back) == + ReaderButtonSliderAction::LeavePane) + closePane(); + else + requestUpdate(); + return; + } closePane(); return; } + if (!mappedInput.hasTouchHardware() && state.pane == ReaderDrawerPane::Percent) { + // getHeldTime() can include another held button in a chord. Confirm's own + // long press only counts while no directional button is held. + const bool directionHeld = mappedInput.isPressed(MappedInputManager::Button::Left) || + mappedInput.isPressed(MappedInputManager::Button::Right) || + mappedInput.isPressed(MappedInputManager::Button::Up) || + mappedInput.isPressed(MappedInputManager::Button::Down); + if (!directionHeld && mappedInput.isPressed(MappedInputManager::Button::Confirm) && + mappedInput.getHeldTime() >= PERCENT_KEYPAD_LONG_PRESS_MS) { + percentConfirmLongPressFired = true; + if (percentKeypadActive) + backspaceKeypadEntry(); + else + enterPercentKeypad(); + return; + } + if (percentKeypadActive) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + activateKeypadFocus(); + return; + } + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { moveKeypadFocus(0, -1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { moveKeypadFocus(0, 1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { moveKeypadFocus(-1, 0); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { moveKeypadFocus(1, 0); }); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + completePercentSelection(); + return; + } + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustPercentSlider(-1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustPercentSlider(1); }); + const int upStep = deviceHasEdgeSideButtons(gpio) ? -10 : 10; + const int downStep = -upStep; + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, + [this, upStep] { adjustPercentSlider(upStep); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, + [this, downStep] { adjustPercentSlider(downStep); }); + return; + } + if (!mappedInput.hasTouchHardware() && state.pane == ReaderDrawerPane::AutoPageTurn) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + completeAutoPageTurnSelection(); + return; + } + const auto adjust = [this](const int delta) { + adjustButtonSlider(delta); + requestUpdate(); + }; + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [&] { adjust(-1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [&] { adjust(1); }); + const int upStep = deviceHasEdgeSideButtons(gpio) ? -5 : 5; + const int downStep = -upStep; + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [&] { adjust(upStep); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [&] { adjust(downStep); }); + return; + } + if (!mappedInput.hasTouchHardware() && readerDrawerStepChangesSettings(state.pane)) { + const auto onDirectionRelease = [this](const ReaderButtonSliderInput input, const int delta) { + const auto action = readerButtonSliderInput(buttonSliderState, input); + if (action == ReaderButtonSliderAction::Increase || action == ReaderButtonSliderAction::Decrease) + adjustButtonSlider(delta); + requestUpdate(); + }; + const auto handleDirection = [&](const MappedInputManager::Button button, const ReaderButtonSliderInput input, + const int delta) { + buttonNavigator.onRelease({button, button}, [&] { onDirectionRelease(input, delta); }); + if (!buttonSliderState.editing) return; + // Held buttons update the draft without refreshing the e-ink panel on + // every repeat; release draws the resulting preview once. + buttonNavigator.onContinuous({button, button}, [this, input, delta] { + const auto action = readerButtonSliderInput(buttonSliderState, input); + if (action == ReaderButtonSliderAction::Increase || action == ReaderButtonSliderAction::Decrease) + adjustButtonSlider(delta); + }); + if (mappedInput.wasReleased(button)) requestUpdate(); + }; + handleDirection(MappedInputManager::Button::Left, ReaderButtonSliderInput::Previous, -1); + handleDirection(MappedInputManager::Button::Right, ReaderButtonSliderInput::Next, 1); + handleDirection(MappedInputManager::Button::Up, ReaderButtonSliderInput::Previous, -5); + handleDirection(MappedInputManager::Button::Down, ReaderButtonSliderInput::Next, 5); + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + readerButtonSliderInput(buttonSliderState, ReaderButtonSliderInput::Confirm); + requestUpdate(); + } + return; + } buttonNavigator.onNextRelease([this] { moveSelection(true, false); }); buttonNavigator.onPreviousRelease([this] { moveSelection(false, false); }); - buttonNavigator.onNextContinuous([this] { - const int next = ButtonNavigator::nextIndex(static_cast(state.tab), READER_DRAWER_TAB_COUNT); - changeTab(static_cast(next)); - buttonFocusActive = true; - }); - buttonNavigator.onPreviousContinuous([this] { - const int previous = ButtonNavigator::previousIndex(static_cast(state.tab), READER_DRAWER_TAB_COUNT); - changeTab(static_cast(previous)); - buttonFocusActive = true; - }); + buttonNavigator.onNextContinuous([this] { moveSelection(true, true); }); + buttonNavigator.onPreviousContinuous([this] { moveSelection(false, true); }); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (!mappedInput.hasTouchHardware() && state.pane == ReaderDrawerPane::Root && !buttonFocusActive) { + changeTab(adjacentReaderDrawerTab(state.tab, true)); + return; + } buttonFocusActive = true; if (state.pane == ReaderDrawerPane::Percent) { completePercentSelection(); @@ -2205,22 +2786,74 @@ void EpubReaderTouchMenuActivity::loop() { } } -void EpubReaderTouchMenuActivity::render(RenderLock&&) { - const int16_t drawerTop = static_cast(renderer.getScreenHeight() - drawerHeight()); - if (previousDrawerTop >= 0 && drawerTop > previousDrawerTop) { - // A shorter pane exposes pixels occupied by the old sheet. Clear them even - // when this page has no text preview (image pages or snapshot allocation failure). - renderer.fillRect(0, previousDrawerTop, renderer.getScreenWidth(), drawerTop - previousDrawerTop, - ReaderUtils::readerDarkModeEnabled()); - previewDirty = true; +void EpubReaderDrawerActivity::render(RenderLock&&) { + const bool buttonDevice = !mappedInput.hasTouchHardware(); + const int16_t drawerEdge = + buttonDevice ? drawerHeight() : static_cast(renderer.getScreenHeight() - drawerHeight()); + if (previousDrawerEdge >= 0 && drawerEdge != previousDrawerEdge) { + // Only the touch sheet changes height; redraw newly exposed reader pixels. + if (drawerEdge > previousDrawerEdge) { + const int16_t exposedTop = std::min(drawerEdge, previousDrawerEdge); + renderer.fillRect(0, exposedTop, renderer.getScreenWidth(), std::abs(drawerEdge - previousDrawerEdge), + ReaderUtils::readerDarkModeEnabled()); + previewDirty = true; + } } - previousDrawerTop = drawerTop; + previousDrawerEdge = drawerEdge; int previewFontId = -1; - const bool previewRendered = renderPreview(previewFontId); + // Keep prewarmed glyphs resident through the BW and optional grayscale passes. + std::optional previewPrewarmScope; + bool previewRendered = !CROSSINK_APP_READER_SAMPLE_PREVIEW && renderPreview(previewFontId, previewPrewarmScope); uiReady = false; + if (CROSSINK_APP_READER_SAMPLE_PREVIEW && fontPreviewLoading) { + GUI.drawPopup(renderer, tr(STR_LOADING_POPUP), true); + fontPreviewLoading = false; + } app.setDevice(uiTarget.deviceContext()); app.render(); + if (buttonDevice) drawButtonBookHeader(); + if (CROSSINK_APP_READER_SAMPLE_PREVIEW) { + previewDirty = true; // The full-screen UI cleared the sample area as well. + previewRendered = renderPreview(previewFontId, previewPrewarmScope); + // cppcheck-suppress knownConditionTrueFalse ; compile-time constant on builds without the sample preview + if (showsSamplePreview() && previewUnavailable) { + app.render(); // A failed font selection rolled the draft back; repaint its values too. + drawButtonBookHeader(); + renderPreviewUnavailable(); + } + } uiReady = true; + if (!mappedInput.hasTouchHardware()) { + const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false); + if (safe.y > 0) renderer.fillRect(0, 0, renderer.getScreenWidth(), safe.y, false); + if (safe.x > 0) renderer.fillRect(0, 0, safe.x, renderer.getScreenHeight(), false); + const int right = renderer.getScreenWidth() - safe.x - safe.width; + const int bottom = renderer.getScreenHeight() - safe.y - safe.height; + if (right > 0) renderer.fillRect(safe.x + safe.width, 0, right, renderer.getScreenHeight(), false); + if (bottom > 0) renderer.fillRect(0, safe.y + safe.height, renderer.getScreenWidth(), bottom, false); + + const char* confirmLabel = tr(STR_SELECT); + if (state.pane == ReaderDrawerPane::AutoPageTurn) { + confirmLabel = tr(STR_CONFIRM); + } else if (state.pane == ReaderDrawerPane::Root && !buttonFocusActive) { + confirmLabel = tr(STR_NEXT_FIELD); + } + const bool fineAdjustment = (state.pane == ReaderDrawerPane::Percent && !percentKeypadActive) || + state.pane == ReaderDrawerPane::AutoPageTurn || + (readerDrawerStepChangesSettings(state.pane) && buttonSliderState.editing); + const char* previousLabel = tr(STR_DIR_UP); + const char* nextLabel = tr(STR_DIR_DOWN); + if (state.pane == ReaderDrawerPane::Percent && percentKeypadActive) { + previousLabel = tr(STR_DIR_LEFT); + nextLabel = tr(STR_DIR_RIGHT); + } else if (fineAdjustment) { + previousLabel = "-1"; + nextLabel = "+1"; + } + const auto labels = + mappedInput.mapLabels(mappedInput.withBackArrow(tr(STR_BACK)), confirmLabel, previousLabel, nextLabel); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4, true); + } renderer.displayBuffer(HalDisplay::FAST_REFRESH); if (shouldRenderReaderDrawerAntiAliasing(previewRendered, draft.textAntiAliasing, ReaderUtils::readerForegroundBlack()) && @@ -2229,7 +2862,7 @@ void EpubReaderTouchMenuActivity::render(RenderLock&&) { } } -const char* EpubReaderTouchMenuActivity::paneTitle() const { +const char* EpubReaderDrawerActivity::paneTitle() const { switch (state.pane) { case ReaderDrawerPane::ReaderFont: return tr(STR_READER_FONT); @@ -2259,11 +2892,13 @@ const char* EpubReaderTouchMenuActivity::paneTitle() const { return tr(STR_SELECT_CHAPTER); case ReaderDrawerPane::Root: return ""; + default: + break; } return ""; } -const char* EpubReaderTouchMenuActivity::rowLabel(const RowId row) const { +const char* EpubReaderDrawerActivity::rowLabel(const RowId row) const { switch (row) { case RowId::ReaderFont: return tr(STR_READER_FONT); @@ -2324,7 +2959,7 @@ const char* EpubReaderTouchMenuActivity::rowLabel(const RowId row) const { case RowId::DeleteBookmarks: return tr(STR_DELETE_BOOKMARKS); case RowId::StatusBar: - return tr(STR_CUSTOMISE_STATUS_BAR); + return tr(STR_STATUS_BARS); case RowId::BookDictionary: return tr(STR_BOOK_DICTIONARY); case RowId::RenderMode: @@ -2345,6 +2980,14 @@ const char* EpubReaderTouchMenuActivity::rowLabel(const RowId row) const { return tr(STR_DELETE_BOOK_STATS); case RowId::AutoPageTurn: return tr(STR_AUTO_TURN_INTERVAL_SECONDS); + case RowId::ReadingStats: + return tr(STR_READING_STATS); + case RowId::SyncProgress: + return tr(STR_SYNC_PROGRESS); + case RowId::NearbyPositionSync: + return tr(STR_NEARBY_POSITION_SYNC); + case RowId::SendNearbyBook: + return tr(STR_SEND_NEARBY_BOOK); case RowId::FontFamily: return tr(STR_FONT_FAMILY); case RowId::FontSize: @@ -2369,11 +3012,13 @@ const char* EpubReaderTouchMenuActivity::rowLabel(const RowId row) const { case RowId::TtfReset: return TtfRenderOptionsActivity::rowLabel(TtfRenderOptionsActivity::Row::Reset); #endif + default: + break; } return ""; } -const char* EpubReaderTouchMenuActivity::rowValue(const RowId row, char* buffer, const size_t bufferSize) const { +const char* EpubReaderDrawerActivity::rowValue(const RowId row, char* buffer, const size_t bufferSize) const { switch (row) { case RowId::FontFamily: { if (draft.sdFontFamilyName[0] != '\0') return draft.sdFontFamilyName.data(); @@ -2454,7 +3099,7 @@ const char* EpubReaderTouchMenuActivity::rowValue(const RowId row, char* buffer, } } -bool EpubReaderTouchMenuActivity::rowIsToggle(const RowId row) { +bool EpubReaderDrawerActivity::rowIsToggle(const RowId row) { switch (row) { case RowId::TextAa: case RowId::Focus: @@ -2473,7 +3118,7 @@ bool EpubReaderTouchMenuActivity::rowIsToggle(const RowId row) { } } -bool EpubReaderTouchMenuActivity::rowShowsNavigationCaret(const RowId row) const { +bool EpubReaderDrawerActivity::rowShowsNavigationCaret(const RowId row) const { if (rowIsToggle(row)) return false; char value[64] = {}; if (rowValue(row, value, sizeof(value)) != nullptr) return false; @@ -2489,13 +3134,16 @@ bool EpubReaderTouchMenuActivity::rowShowsNavigationCaret(const RowId row) const case RowId::DeleteBookmarks: case RowId::DeleteCache: case RowId::DeleteStats: + case RowId::SyncProgress: + case RowId::NearbyPositionSync: + case RowId::SendNearbyBook: return false; default: return true; } } -bool EpubReaderTouchMenuActivity::rowToggleValue(const RowId row) const { +bool EpubReaderDrawerActivity::rowToggleValue(const RowId row) const { switch (row) { case RowId::TextAa: return draft.textAntiAliasing; @@ -2521,5 +3169,3 @@ bool EpubReaderTouchMenuActivity::rowToggleValue(const RowId row) const { return false; } } - -#endif diff --git a/src/activities/reader/EpubReaderTouchMenuActivity.h b/src/activities/reader/EpubReaderDrawerActivity.h similarity index 77% rename from src/activities/reader/EpubReaderTouchMenuActivity.h rename to src/activities/reader/EpubReaderDrawerActivity.h index f131f789367..ab41f41cf4f 100644 --- a/src/activities/reader/EpubReaderTouchMenuActivity.h +++ b/src/activities/reader/EpubReaderDrawerActivity.h @@ -1,10 +1,8 @@ #pragma once #include - -#if CROSSINK_APP_CAP_TOUCH - #include +#include #include #include @@ -12,6 +10,7 @@ #include #include #include +#include #include #include @@ -24,23 +23,27 @@ #include "TtfRenderProfileStore.h" #endif -class EpubReaderTouchMenuActivity final : public Activity { +class EpubReaderDrawerActivity final : public Activity { public: - explicit EpubReaderTouchMenuActivity( + explicit EpubReaderDrawerActivity( GfxRenderer& renderer, MappedInputManager& mappedInput, std::shared_ptr epub, - const TouchReaderPreviewModel* previewModel, float bookProgressPercent, bool hasFootnotes, bool hasDictionary, + const EpubReaderPreviewModel* previewModel, float bookProgressPercent, uint32_t chapterPage, + uint32_t chapterPageCount, bool chapterPageCountEstimated, bool hasFootnotes, bool hasDictionary, bool hasBookmarks, bool hasClippings, bool isCurrentPageBookmarked, bool isBookCompleted, bool showReadingPaceReset, uint32_t stableCurrentPage, uint32_t stablePageCount, uint16_t autoPageTurnIntervalSeconds, bool automaticPageTurnActive, - ReaderOptionsActivity::SaveSettingsCallback saveReaderSettingsCallback, void* saveReaderSettingsContext, - ReaderOptionsActivity::SaveGlobalSettingsCallback saveGlobalSettingsCallback, void* saveGlobalSettingsContext, - ReaderOptionsActivity::GlobalSettingsEditCallback beginGlobalSettingsEditCallback, - void* beginGlobalSettingsEditContext, - ReaderOptionsActivity::GlobalSettingsEditCallback endGlobalSettingsEditCallback, - void* endGlobalSettingsEditContext, const char* dictionaryFontFamilyName, uint8_t dictionaryFontPointSize, - bool hasDictionaryFontOverride, - ReaderOptionsActivity::DictionaryFontChangedCallback dictionaryFontChangedCallback, - void* dictionaryFontChangedContext, const ReaderDrawerState& initialState = {}); + ReaderOptionsActivity::SaveSettingsCallback saveReaderSettingsCallback = nullptr, + void* saveReaderSettingsContext = nullptr, + ReaderOptionsActivity::SaveGlobalSettingsCallback saveGlobalSettingsCallback = nullptr, + void* saveGlobalSettingsContext = nullptr, + ReaderOptionsActivity::GlobalSettingsEditCallback beginGlobalSettingsEditCallback = nullptr, + void* beginGlobalSettingsEditContext = nullptr, + ReaderOptionsActivity::GlobalSettingsEditCallback endGlobalSettingsEditCallback = nullptr, + void* endGlobalSettingsEditContext = nullptr, const char* dictionaryFontFamilyName = nullptr, + uint8_t dictionaryFontPointSize = 0, bool hasDictionaryFontOverride = false, + ReaderOptionsActivity::DictionaryFontChangedCallback dictionaryFontChangedCallback = nullptr, + void* dictionaryFontChangedContext = nullptr, const ReaderDrawerState& initialState = {}, + std::unique_ptr ownedPreviewModel = nullptr); void onEnter() override; void onExit() override; @@ -50,9 +53,14 @@ class EpubReaderTouchMenuActivity final : public Activity { bool allowFrontlightPanelGesture() const override { return false; } // A dirty preview can rebuild the page area itself; after a TTF ID change, // an unavailable text snapshot is replaced with a safe blank background. + // cppcheck-suppress uselessOverride ; identical to the base only when the sample preview is built in bool requiresFreshBackdrop() const override { +#if CROSSINK_APP_READER_SAMPLE_PREVIEW + return false; // The full-screen menu paints every pixel itself. +#else return readerDrawerNeedsExternalBackdrop(previewDirty, previewModel && previewModel->valid(), previewFontMetricsChanged); +#endif } bool allowPowerAsConfirmInReaderMode() const override { return true; } @@ -79,7 +87,12 @@ class EpubReaderTouchMenuActivity final : public Activity { static constexpr size_t WINDOW_SIZE = 20; std::shared_ptr epub; - const TouchReaderPreviewModel* previewModel = nullptr; + const EpubReaderPreviewModel* previewModel = nullptr; + // Button devices own their bounded sample paragraph while the menu is open. + // Touch devices use the reader's current-page preview instead. + std::unique_ptr ownedPreviewModel; + bool previewUnavailable = false; + freeink::ui::Rect samplePreviewBounds{}; // Centipercent (0-10000, hundredths of a percent) so the keypad can type a decimal // destination; 1.00% is 100 here. int percent = 0; @@ -89,11 +102,20 @@ class EpubReaderTouchMenuActivity final : public Activity { // resetKeypadEntry() so backspacing everything or leaving and reopening the pane // shows the book's actual position again rather than an abandoned typed value. const int percentSeed = 0; + const uint32_t chapterPage = 0; + const uint32_t chapterPageCount = 0; + const bool chapterPageCountEstimated = false; const uint32_t stablePageSeed = 0; - // Numeric keypad entry for the Percent/StablePage panes (typed digits, not the old - // slider), matching EpubReaderPercentSelectionActivity's non-touch keypad. + // Touch uses the Percent keypad directly. Button devices start on the slider + // and can open this keypad by holding Confirm, as in the old selector. char entryText[8] = {0}; uint8_t entryLen = 0; + uint8_t keypadRow = 0; + uint8_t keypadCol = 0; + bool keypadBackspaceFocused = false; + bool percentKeypadActive = false; + bool percentConfirmLongPressFired = false; + int percentBeforeKeypad = 0; bool hasFootnotes = false; bool hasDictionary = false; bool hasBookmarks = false; @@ -106,16 +128,18 @@ class EpubReaderTouchMenuActivity final : public Activity { bool previewDirty = false; bool previewFontMetricsChanged = false; bool fontPreviewLoading = false; - int16_t previousDrawerTop = -1; + int16_t previousDrawerEdge = -1; bool draggingSlider = false; bool sliderTapPending = false; bool buttonFocusActive = false; + ReaderButtonSliderState buttonSliderState{}; bool automaticPageTurnActive = false; uint16_t autoPageTurnIntervalSeconds = READER_AUTO_PAGE_TURN_MIN_SECONDS; ReaderDrawerState state{}; ReaderSettingsDraft draft{}; const ReaderSettingsDraft sourceSettings; + ReaderSettingsDraft lastGoodPreviewSettings{}; ReaderSettingsChangeMask changeMask = ReaderSettingsChangeMask::None; std::array, READER_DRAWER_TAB_COUNT> rootRows; std::vector paneRows; @@ -169,6 +193,10 @@ class EpubReaderTouchMenuActivity final : public Activity { static void onKeypadBackspaceEvent(const freeink::ui::ActionEvent& event, void* user); void buildDrawer(UiApp::ScreenType& screen); + void drawButtonBookHeader(); + bool showsSamplePreview() const; + void renderPreviewUnavailable(); + void renderSamplePreviewText(const ReaderSettingsDraft& settings, int fontId); void buildTabBar(UiApp::ScreenType& screen, freeink::ui::Rect rect, bool drawBottomRule); void buildPaneHeader(UiApp::ScreenType& screen); void buildRootRows(UiApp::ScreenType& screen); @@ -176,11 +204,11 @@ class EpubReaderTouchMenuActivity final : public Activity { void buildSpacingPane(UiApp::ScreenType& screen); void buildMarginsPane(UiApp::ScreenType& screen); void buildPercentPane(UiApp::ScreenType& screen); + void buildPercentSlider(UiApp::ScreenType& screen); void buildStablePagePane(UiApp::ScreenType& screen); // Shared by both panes: a readout with a backspace icon, and a 4x3 grid (1-9 / 0, - // ., OK; Percent only enables "."). Unlike every other pane, there is no separate - // Confirm button here - OK lives in the grid instead, since the sheet has no room - // for both. The pre-existing hardware-Confirm handling in loop() still applies. + // ., OK; Percent only enables "."). There is no separate Confirm button; button + // devices move focus through the grid and press Confirm on OK. void buildDrawerKeypad(UiApp::ScreenType& screen, bool allowDecimal, const char* value); void buildAutoPageTurnPane(UiApp::ScreenType& screen); void buildConfirmButton(UiApp::ScreenType& screen); @@ -214,17 +242,23 @@ class EpubReaderTouchMenuActivity final : public Activity { void notifyDictionaryFontChanged(); void toggleSetting(RowId row); void adjustActiveSlider(int delta); + void adjustButtonSlider(int direction); void setActiveSliderPermille(int16_t permille); void appendKeypadDigit(char digit); void appendKeypadDecimalPoint(); void backspaceKeypadEntry(); void resetKeypadEntry(); - // Parses entryText (if any digits were typed) into percent/stablePage so both the - // grid's OK key and the pane's hardware-Confirm handling in loop() always read the - // latest typed value. + void enterPercentKeypad(); + void exitPercentKeypad(); + void adjustPercentSlider(int steps); + void moveKeypadFocus(int rowDelta, int colDelta); + void activateKeypadFocus(); + // Parses entryText (if any digits were typed) into percent/stablePage so the + // grid's OK key always reads the latest typed value. void syncKeypadValue(); int16_t drawerHeight() const; - bool renderPreview(int& previewFontId); + freeink::ui::Rect previewBounds() const; + bool renderPreview(int& previewFontId, std::optional& prewarmScope); void renderPreviewWithAntiAliasing(int previewFontId); void renderPreviewContents(const ReaderSettingsDraft& previewSettings, int previewFontId); void renderPreviewText(const ReaderSettingsDraft& previewSettings, int previewFontId); @@ -249,5 +283,3 @@ class EpubReaderTouchMenuActivity final : public Activity { bool rowToggleValue(RowId row) const; const char* paneTitle() const; }; - -#endif diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp deleted file mode 100644 index bf8019a6bda..00000000000 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ /dev/null @@ -1,784 +0,0 @@ -#include "EpubReaderMenuActivity.h" - -#include -#include -#include - -#include -#include - -#include "ClippingStore.h" -#include "CrossInkHalFrontlight.h" -#include "CrossPointSettings.h" -#include "EpubReaderClippingListActivity.h" -#include "EpubReaderPercentSelectionActivity.h" -#include "MappedInputManager.h" -#include "Memory.h" -#include "ReaderUtils.h" -#include "components/TouchHeaderBackButton.h" -#include "components/TouchRegistry.h" -#include "components/UITheme.h" -#include "components/UIThemeTokens.h" -#include "components/UiAppHelpers.h" -#include "components/icons/touchscreenStateIcons.h" -#include "fontIds.h" - -namespace fui = freeink::ui; - -namespace { - -constexpr fui::ActionId ACTION_ROW = 1; - -constexpr int tabIconSize = 24; -constexpr int selectedTabBoxWidth = 50; -constexpr int selectedTabBoxHeight = 34; -constexpr int selectedTabBoxRadius = 2; -constexpr int headerActionHitSize = 44; -constexpr int headerActionTouchSize = 60; -constexpr int headerActionGap = 10; -constexpr int headerActionRightPadding = 10; -constexpr int inlineBatteryReserve = 72; -constexpr int touchReaderMenuRowHeightScale = 2; -constexpr int touchReaderMenuTabBarHeightScale = 2; -int readerMenuRowHeightScale(const bool hasTouch) { return hasTouch ? touchReaderMenuRowHeightScale : 1; } - -int readerMenuTabBarHeight(const int baseTabBarHeight, const bool hasTouch) { - return baseTabBarHeight * (hasTouch ? touchReaderMenuTabBarHeightScale : 1); -} - -#if CROSSINK_APP_CAP_TOUCH -bool readerMenuTabsAtBottom(const MappedInputManager& mappedInput) { - // Frontlight boards reserve the top-edge down-swipe for the quick panel, so - // the reader menu opens from the bottom and its tabs should stay thumb-close. - return mappedInput.hasTouch() && Frontlight.present(); -} -#endif - -Rect readerMenuHeaderRect(const GfxRenderer& renderer, const MappedInputManager& mappedInput) { - const Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, !mappedInput.hasTouch(), false); - const auto& metrics = UITheme::getInstance().getMetrics(); - return Rect{screen.x, screen.y + metrics.topPadding, screen.width, - TouchHeaderBackButton::height(metrics, mappedInput)}; -} - -bool rectContains(const Rect& rect, const int x, const int y) { - return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height; -} - -struct ReaderLayoutSettingsSnapshot { - uint8_t fontFamily; - uint8_t readerFontPointSize; - uint8_t lineHeightPercent; - uint8_t wordSpacing; - uint8_t orientation; - uint8_t screenMarginVertical; - uint8_t screenMarginHorizontal; - uint8_t publisherPageNumbers; - uint8_t paragraphAlignment; - uint8_t embeddedStyle; - uint8_t hyphenationEnabled; - uint8_t textAntiAliasing; - uint8_t imageRendering; - uint8_t extraParagraphSpacing; - uint8_t forceParagraphIndents; - uint8_t focusReadingEnabled; - uint8_t guideReadingEnabled; - uint8_t epubRenderMode; - // Indexing method is a build policy, not a layout input. A mode-only change - // keeps the live section/parser and takes effect when the next chapter opens. - char sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName)] = {}; -}; - -ReaderLayoutSettingsSnapshot captureReaderLayoutSettings() { - ReaderLayoutSettingsSnapshot snapshot{ - SETTINGS.fontFamily, - SETTINGS.readerFontPointSize, - SETTINGS.lineHeightPercent, - SETTINGS.wordSpacing, - SETTINGS.orientation, - SETTINGS.screenMarginVertical, - SETTINGS.screenMarginHorizontal, - SETTINGS.publisherPageNumbers, - SETTINGS.paragraphAlignment, - SETTINGS.embeddedStyle, - SETTINGS.hyphenationEnabled, - SETTINGS.textAntiAliasing, - SETTINGS.imageRendering, - SETTINGS.extraParagraphSpacing, - SETTINGS.forceParagraphIndents, - SETTINGS.focusReadingEnabled, - SETTINGS.guideReadingEnabled, - SETTINGS.epubRenderMode, - }; - std::strncpy(snapshot.sdFontFamilyName, SETTINGS.sdFontFamilyName, sizeof(snapshot.sdFontFamilyName) - 1); - snapshot.sdFontFamilyName[sizeof(snapshot.sdFontFamilyName) - 1] = '\0'; - return snapshot; -} - -ReaderSettingsChangeMask classifyReaderSettingsChange(const ReaderLayoutSettingsSnapshot& before, - const ReaderLayoutSettingsSnapshot& after) { - ReaderSettingsChangeMask changeMask = ReaderSettingsChangeMask::None; - - if (before.textAntiAliasing != after.textAntiAliasing) { - changeMask = ReaderSettingsChangeMask::NonLayout; - } - if (before.orientation != after.orientation) { - changeMask = changeMask | ReaderSettingsChangeMask::Orientation; - } - if (before.fontFamily != after.fontFamily || before.readerFontPointSize != after.readerFontPointSize || - before.lineHeightPercent != after.lineHeightPercent || before.wordSpacing != after.wordSpacing || - before.screenMarginVertical != after.screenMarginVertical || - before.screenMarginHorizontal != after.screenMarginHorizontal || - before.publisherPageNumbers != after.publisherPageNumbers || - before.paragraphAlignment != after.paragraphAlignment || before.embeddedStyle != after.embeddedStyle || - before.hyphenationEnabled != after.hyphenationEnabled || - before.extraParagraphSpacing != after.extraParagraphSpacing || - before.forceParagraphIndents != after.forceParagraphIndents || - before.focusReadingEnabled != after.focusReadingEnabled || - before.guideReadingEnabled != after.guideReadingEnabled || before.imageRendering != after.imageRendering || - before.epubRenderMode != after.epubRenderMode || - std::strncmp(before.sdFontFamilyName, after.sdFontFamilyName, sizeof(before.sdFontFamilyName)) != 0) { - changeMask = changeMask | ReaderSettingsChangeMask::Relayout; - } - return changeMask; -} - -void drawBookmarkTabIcon(const GfxRenderer& renderer, int x, int y, const bool foregroundBlack = true) { - constexpr int ribbonWidth = 16; - constexpr int ribbonHeight = 22; - constexpr int notchSize = 6; - const int iconX = x + (tabIconSize - ribbonWidth) / 2; - const int iconY = y + 1; - const int centerX = iconX + ribbonWidth / 2; - - const int polyX[5] = {iconX, iconX + ribbonWidth, iconX + ribbonWidth, centerX, iconX}; - const int polyY[5] = {iconY, iconY, iconY + ribbonHeight, iconY + ribbonHeight - notchSize, iconY + ribbonHeight}; - renderer.fillPolygon(polyX, polyY, 5, foregroundBlack); -} - -Rect readerMenuHeaderActionRect(const Rect& header, const ThemeMetrics& metrics) { - const int actionHeight = std::min(header.height, headerActionHitSize); - // Compact headers keep the battery inline at the right edge, so leave its - // widest percentage-and-glyph band untouched. Tall detached headers place - // Home in the lower title band, as in the touch-menu design. - const int rightInset = (metrics.headerBatteryDetached ? 0 : inlineBatteryReserve) + headerActionRightPadding; - return Rect{header.x + header.width - rightInset - headerActionHitSize, header.y + header.height - actionHeight, - headerActionHitSize, actionHeight}; -} - -Rect readerMenuHeaderActionTouchRect(const Rect& header, const Rect& actionRect) { - const int touchWidth = std::min(headerActionTouchSize, header.width); - const int touchX = actionRect.x + actionRect.width - touchWidth; - // The title reserves the space left of touchX. Treat the remaining header - // corner, including the non-interactive battery area, as Home so the icon is - // easy to hit without changing its visual placement. - return Rect{touchX, header.y, header.x + header.width - touchX, header.height}; -} - -void drawSdkIcon(fui::GfxRendererTarget& target, const freeink::Icon& icon, const int x, const int y, - const bool foregroundBlack = true) { - // FreeInkUI's target maps logical pixels through the renderer, keeping the - // non-pre-rotated SDK assets upright in every reader orientation. - target.bitmap(fui::Rect{static_cast(x), static_cast(y), static_cast(icon.w), - static_cast(icon.h)}, - fui::bitmapFromIcon(icon), fui::BitmapMode::Center, - fui::Paint::solid(foregroundBlack ? fui::Color::Black : fui::Color::White)); -} - -} // namespace - -EpubReaderMenuActivity::EpubReaderMenuActivity( - GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, const int currentPage, - const int totalPages, const int bookProgressPercent, const uint8_t currentOrientation, const bool hasFootnotes, - const bool hasDictionary, const bool hasBookmarks, const bool hasClippings, const bool isCurrentPageBookmarked, - const bool isBookCompleted, const bool autoPageTurnActive, const uint16_t autoPageTurnIntervalSeconds, - const bool showReadingPaceReset, ReaderOptionsActivity::SaveSettingsCallback saveReaderSettingsCallback, - void* saveReaderSettingsContext, ReaderOptionsActivity::SaveGlobalSettingsCallback saveGlobalSettingsCallback, - void* saveGlobalSettingsContext, ReaderOptionsActivity::GlobalSettingsEditCallback beginGlobalSettingsEditCallback, - void* beginGlobalSettingsEditContext, const uint32_t stableCurrentPage, const uint32_t stablePageCount, - ReaderOptionsActivity::GlobalSettingsEditCallback endGlobalSettingsEditCallback, void* endGlobalSettingsEditContext, - const char* dictionaryFontFamilyName, const uint8_t dictionaryFontPointSize, const bool hasDictionaryFontOverride, - ReaderOptionsActivity::DictionaryFontChangedCallback dictionaryFontChangedCallback, - void* dictionaryFontChangedContext) - : Activity("EpubReaderMenu", renderer, mappedInput), - menuItems(buildMenuItems(hasFootnotes, hasBookmarks, hasClippings, isCurrentPageBookmarked, isBookCompleted, - showReadingPaceReset, hasDictionary, stablePageCount > 0)), - title(title), - pendingOrientation(currentOrientation), - currentPage(currentPage), - totalPages(totalPages), - bookProgressPercent(bookProgressPercent), - autoPageTurnActive(autoPageTurnActive), - autoPageTurnIntervalSeconds(autoPageTurnIntervalSeconds), - saveReaderSettingsCallback(saveReaderSettingsCallback), - saveReaderSettingsContext(saveReaderSettingsContext), - saveGlobalSettingsCallback(saveGlobalSettingsCallback), - saveGlobalSettingsContext(saveGlobalSettingsContext), - beginGlobalSettingsEditCallback(beginGlobalSettingsEditCallback), - beginGlobalSettingsEditContext(beginGlobalSettingsEditContext), - stableCurrentPage(stableCurrentPage), - stablePageCount(stablePageCount), - endGlobalSettingsEditCallback(endGlobalSettingsEditCallback), - endGlobalSettingsEditContext(endGlobalSettingsEditContext), - dictionaryFontPointSize(dictionaryFontPointSize), - hasDictionaryFontOverride(hasDictionaryFontOverride), - dictionaryFontChangedCallback(dictionaryFontChangedCallback), - dictionaryFontChangedContext(dictionaryFontChangedContext), - uiTarget(makeUiTarget(renderer)), - app(uiTarget, uiTarget.deviceContext()) { - if (dictionaryFontFamilyName) { - std::strncpy(this->dictionaryFontFamilyName, dictionaryFontFamilyName, sizeof(this->dictionaryFontFamilyName) - 1); - } -} - -EpubReaderMenuActivity::TabMenuItems EpubReaderMenuActivity::buildMenuItems( - bool hasFootnotes, bool hasBookmarks, bool hasClippings, bool isCurrentPageBookmarked, bool isBookCompleted, - bool showReadingPaceReset, bool hasDictionary, bool hasStablePageNumbers) { - TabMenuItems items; - auto& mainItems = items[MAIN_TAB_INDEX]; - auto& bookmarkItems = items[BOOKMARKS_TAB_INDEX]; - auto& settingsItems = items[SETTINGS_TAB_INDEX]; - - mainItems.reserve(9 + (hasFootnotes ? 1u : 0u) + (hasDictionary ? 2u : 0u)); - bookmarkItems.reserve(9 + (hasBookmarks ? 2u : 0u) + (hasClippings ? 1u : 0u)); - settingsItems.reserve(6); - - if (hasFootnotes) { - mainItems.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES}); - } - if (hasDictionary) { - mainItems.push_back({MenuAction::LOOKUP, StrId::STR_LOOKUP}); - mainItems.push_back({MenuAction::LOOKUP_HISTORY, StrId::STR_LOOKUP_HISTORY}); - } - mainItems.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); - mainItems.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT}); - if (hasStablePageNumbers) { - mainItems.push_back({MenuAction::GO_TO_STABLE_PAGE, StrId::STR_GO_TO_STABLE_PAGE}); - } - mainItems.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_INTERVAL_SECONDS}); - mainItems.push_back({MenuAction::READING_STATS, StrId::STR_READING_STATS}); - mainItems.push_back({MenuAction::READER_OPTIONS, StrId::STR_READER_OPTIONS}); - bookmarkItems.push_back({MenuAction::SAVE_CLIPPING, StrId::STR_SAVE_CLIPPING}); - if (hasClippings) { - bookmarkItems.push_back({MenuAction::VIEW_CLIPPINGS, StrId::STR_VIEW_CLIPPINGS}); - } - bookmarkItems.push_back( - {MenuAction::BOOKMARK_TOGGLE, isCurrentPageBookmarked ? StrId::STR_REMOVE_BOOKMARK : StrId::STR_ADD_BOOKMARK}); - if (hasBookmarks) { - bookmarkItems.push_back({MenuAction::VIEW_BOOKMARKS, StrId::STR_VIEW_BOOKMARKS}); - bookmarkItems.push_back({MenuAction::DELETE_BOOKMARKS, StrId::STR_DELETE_BOOKMARKS}); - } - bookmarkItems.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS}); - bookmarkItems.push_back({MenuAction::NEARBY_POSITION_SYNC, StrId::STR_NEARBY_POSITION_SYNC}); - bookmarkItems.push_back({MenuAction::SEND_NEARBY_BOOK, StrId::STR_SEND_NEARBY_BOOK}); - bookmarkItems.push_back({MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON}); - bookmarkItems.push_back({MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR}); - - settingsItems.push_back({MenuAction::SET_BOOK_DICTIONARY, StrId::STR_BOOK_DICTIONARY}); - settingsItems.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}); - settingsItems.push_back({MenuAction::DELETE_STATS, StrId::STR_DELETE_BOOK_STATS}); - if (showReadingPaceReset) { - settingsItems.push_back({MenuAction::RESET_READING_PACE, StrId::STR_RESET_READING_PACE}); - } - settingsItems.push_back({MenuAction::CONTROLS_OPTIONS, StrId::STR_CAT_CONTROLS}); - settingsItems.push_back( - {MenuAction::TOGGLE_COMPLETED, isBookCompleted ? StrId::STR_MARK_UNFINISHED : StrId::STR_MARK_FINISHED}); - return items; -} - -void EpubReaderMenuActivity::dictionaryFontChangedForMenu(void* ctx, const char* familyName, const uint8_t pointSize) { - auto* self = static_cast(ctx); - if (!self) return; - - if (familyName && familyName[0] != '\0') { - self->hasDictionaryFontOverride = true; - std::strncpy(self->dictionaryFontFamilyName, familyName, sizeof(self->dictionaryFontFamilyName) - 1); - self->dictionaryFontFamilyName[sizeof(self->dictionaryFontFamilyName) - 1] = '\0'; - } else { - self->hasDictionaryFontOverride = false; - std::strncpy(self->dictionaryFontFamilyName, SETTINGS.dictionarySdFontFamilyName, - sizeof(self->dictionaryFontFamilyName) - 1); - self->dictionaryFontFamilyName[sizeof(self->dictionaryFontFamilyName) - 1] = '\0'; - self->dictionaryFontPointSize = SETTINGS.dictionaryFontPointSize; - } - if (self->hasDictionaryFontOverride) { - self->dictionaryFontPointSize = pointSize; - } - if (self->dictionaryFontChangedCallback) { - self->dictionaryFontChangedCallback(self->dictionaryFontChangedContext, - self->hasDictionaryFontOverride ? self->dictionaryFontFamilyName : nullptr, - self->dictionaryFontPointSize); - } -} - -const std::vector& EpubReaderMenuActivity::activeMenuItems() const { - return menuItems[activeTabIndex()]; -} - -void EpubReaderMenuActivity::focusTabRow() { - selectedIndex = -1; - topIndex = 0; -} - -void EpubReaderMenuActivity::cycleActiveTab() { moveActiveTab(true); } - -void EpubReaderMenuActivity::moveActiveTab(const bool forward) { - const int nextTabIndex = forward ? ButtonNavigator::nextIndex(static_cast(activeTabIndex()), MENU_TAB_COUNT) - : ButtonNavigator::previousIndex(static_cast(activeTabIndex()), MENU_TAB_COUNT); - activeTab = static_cast(nextTabIndex); - focusTabRow(); - requestUpdate(); -} - -void EpubReaderMenuActivity::finishCancelled() { - ActivityResult result; - result.isCancelled = true; - result.data = makeMenuResult(-1); - setResult(std::move(result)); - finish(); -} - -MenuResult EpubReaderMenuActivity::makeMenuResult(const int action) const { - MenuResult result{action, pendingOrientation, settingsChanged}; - result.changeMask = changeMask; - return result; -} - -bool EpubReaderMenuActivity::activateSelectedItem() { - if (selectedIndex < 0) { - cycleActiveTab(); - return true; - } - - const auto& items = activeMenuItems(); - if (selectedIndex >= static_cast(items.size())) { - focusTabRow(); - requestUpdate(); - return true; - } - - const auto selectedAction = items[selectedIndex].action; - if (selectedAction == MenuAction::ROTATE_SCREEN) { - optionPopup.show(StrId::STR_ORIENTATION, orientationLabels.data(), static_cast(orientationLabels.size()), - pendingOrientation, [this](int idx) { - pendingOrientation = idx; - // Rotate the menu immediately while leaving the saved - // reader orientation for the result handler to apply. - ReaderUtils::applyOrientation(renderer, pendingOrientation); - app.setDevice(uiTarget.deviceContext()); - requestUpdate(true); - }); - requestUpdate(); - return true; - } - - if (selectedAction == MenuAction::READER_OPTIONS) { - const auto before = captureReaderLayoutSettings(); - startActivityForResult( - std::make_unique( - renderer, mappedInput, saveReaderSettingsCallback, saveReaderSettingsContext, saveGlobalSettingsCallback, - saveGlobalSettingsContext, beginGlobalSettingsEditCallback, beginGlobalSettingsEditContext, - endGlobalSettingsEditCallback, endGlobalSettingsEditContext, stablePageCount > 0, dictionaryFontFamilyName, - dictionaryFontPointSize, hasDictionaryFontOverride, dictionaryFontChangedForMenu, this), - [this, before](const ActivityResult& result) { - ReaderSettingsChangeMask changed = classifyReaderSettingsChange(before, captureReaderLayoutSettings()); - if (const auto* options = std::get_if(&result.data); - options && options->activeFamilyChanged) { - changed = changed | ReaderSettingsChangeMask::Relayout; - } - if (changed != ReaderSettingsChangeMask::None) { - settingsChanged = true; - changeMask = changeMask | changed; - } - pendingOrientation = SETTINGS.orientation; // sync in case orientation changed - if (result.isCancelled) { - finishCancelled(); - return; - } - requestUpdate(); - }); - return true; - } - - if (selectedAction == MenuAction::CONTROLS_OPTIONS) { - if (beginGlobalSettingsEditCallback) beginGlobalSettingsEditCallback(beginGlobalSettingsEditContext); - startActivityForResult(std::make_unique(renderer, mappedInput), - [this](const ActivityResult&) { - if (endGlobalSettingsEditCallback) - endGlobalSettingsEditCallback(endGlobalSettingsEditContext); - ActivityResult result; - result.isCancelled = true; - result.data = makeMenuResult(-1); - setResult(std::move(result)); - finish(); - }); - return true; - } - - if (selectedAction == MenuAction::GO_TO_STABLE_PAGE) { - auto selector = makeUniqueNoThrow(renderer, mappedInput, stableCurrentPage, - stablePageCount); - if (!selector) { - LOG_ERR("ERM", "Could not allocate stable page selector"); - requestUpdate(); - return true; - } - startActivityForResult(std::move(selector), [this](const ActivityResult& result) { - if (result.isCancelled) { - requestUpdate(); - return; - } - MenuResult menu = makeMenuResult(static_cast(MenuAction::GO_TO_STABLE_PAGE)); - menu.drawerPage = std::get(result.data).page; - setResult(std::move(menu)); - finish(); - }); - return true; - } - - if (selectedAction == MenuAction::VIEW_CLIPPINGS) { - startActivityForResult(std::make_unique(renderer, mappedInput), - [this](const ActivityResult& result) { - if (result.isCancelled) { - requestUpdate(); - return; - } - - const auto* clipping = std::get_if(&result.data); - if (clipping == nullptr) { - requestUpdate(); - return; - } - - ClippingJumpResult menuResult = *clipping; - menuResult.orientation = pendingOrientation; - menuResult.settingsChanged = settingsChanged; - setResult(std::move(menuResult)); - finish(); - }); - return true; - } - - setResult(makeMenuResult(static_cast(selectedAction))); - finish(); - return true; -} - -bool EpubReaderMenuActivity::handleTouchInput() { - int tabIndex = -1; - if (mappedInput.wasTabTapped(tabIndex) && tabIndex >= 0) { - if (mappedInput.hasTouchHardware() && tabIndex == static_cast(TOUCH_LOCK_ICON_INDEX)) { - SETTINGS.disableReaderTouchscreen = SETTINGS.disableReaderTouchscreen ? 0 : 1; - if (saveGlobalSettingsCallback) { - saveGlobalSettingsCallback(saveGlobalSettingsContext); - } else { - SETTINGS.saveToFile(); - } - requestUpdate(); - return true; - } - if (mappedInput.hasTouchHardware() && tabIndex == static_cast(TOUCH_HOME_ICON_INDEX)) { - setResult(makeMenuResult(static_cast(MenuAction::GO_HOME))); - finish(); - return true; - } - if (tabIndex < static_cast(MENU_TAB_COUNT)) { - activeTab = static_cast(tabIndex); - focusTabRow(); - requestUpdate(); - return true; - } - } - return false; -} - -void EpubReaderMenuActivity::onRowEvent(const fui::ActionEvent& event, void* user) { - auto* self = static_cast(user); - const auto& items = self->activeMenuItems(); - if (self->optionPopup.isActive() || event.value < 0 || event.value >= static_cast(items.size())) return; - self->selectedIndex = event.value; - self->app.clearTapFlash(); - self->activateSelectedItem(); -} - -void EpubReaderMenuActivity::drawIconTabBar(const Rect rect, const bool drawBottomBorder) { - renderer.drawLine(rect.x, rect.y, rect.x + rect.width - 1, rect.y, true); - if (drawBottomBorder) { - renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true); - } - -#if CROSSINK_APP_CAP_TOUCH - const size_t iconCount = mappedInput.hasTouchHardware() ? TOUCH_ICON_COUNT : MENU_TAB_COUNT; -#else - constexpr size_t iconCount = MENU_TAB_COUNT; -#endif - for (size_t i = 0; i < iconCount; i++) { - const int slotX = rect.x + static_cast((i * rect.width) / iconCount); - const int nextSlotX = rect.x + static_cast(((i + 1) * rect.width) / iconCount); - const int slotWidth = nextSlotX - slotX; - const int centerX = slotX + slotWidth / 2; - const bool selected = i < MENU_TAB_COUNT && i == activeTabIndex(); - const bool tabFocused = selected && selectedIndex < 0; - TouchRegistry::getInstance().add(Rect{slotX, rect.y, slotWidth, rect.height}, static_cast(i), - TouchRegistry::Tab); - const int boxX = centerX - selectedTabBoxWidth / 2; - const int boxY = rect.y + (rect.height - selectedTabBoxHeight) / 2; - const int iconX = centerX - tabIconSize / 2; - const int iconY = rect.y + (rect.height - tabIconSize) / 2; - - if (tabFocused) { - renderer.fillRoundedRect(boxX, boxY, selectedTabBoxWidth, selectedTabBoxHeight, selectedTabBoxRadius, - Color::Black); - } else if (selected) { - renderer.drawRoundedRect(boxX, boxY, selectedTabBoxWidth, selectedTabBoxHeight, 1, selectedTabBoxRadius, true); - } - - if (i == static_cast(MenuTab::Main)) { - drawSdkIcon(uiTarget, icon_menu_24, iconX, iconY, !tabFocused); - } else if (i == static_cast(MenuTab::Bookmarks)) { - drawBookmarkTabIcon(renderer, iconX, iconY, !tabFocused); - } else if (i == static_cast(MenuTab::Settings)) { - drawSdkIcon(uiTarget, icon_cog_24, iconX, iconY, !tabFocused); - } -#if CROSSINK_APP_CAP_TOUCH - else { - drawSdkIcon(uiTarget, SETTINGS.disableReaderTouchscreen ? icon_device_tablet_off_24 : icon_device_tablet_24, - iconX, iconY); - } -#endif - } -} - -void EpubReaderMenuActivity::onEnter() { - Activity::onEnter(); - mappedInput.setReaderTouchscreenOverride(true); - uiReady = false; - visibleRows = 1; - topIndex = 0; - applySharedUiTheme(app, uiTarget); - app.on(ACTION_ROW, &EpubReaderMenuActivity::onRowEvent, this); - app.setScreen(&EpubReaderMenuActivity::menuScreen, this); - requestUpdate(); -} - -void EpubReaderMenuActivity::onExit() { - mappedInput.setReaderTouchscreenOverride(false); - Activity::onExit(); -} - -void EpubReaderMenuActivity::loop() { - if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; - if (TouchHeaderBackButton::wasTapped(mappedInput, readerMenuHeaderRect(renderer, mappedInput))) { - finishCancelled(); - return; - } - if (handleTouchInput()) return; - if (mappedInput.wasHomeGesture()) { - finishCancelled(); - return; - } - - // On X4 Pro, the top-edge down-swipe is the opposite of the reader menu's - // upward opening gesture. It dismisses before generic swipe scrolling. - if (ReaderUtils::isTouchMenuDismissGesture(mappedInput)) { - finishCancelled(); - return; - } - - // A home-key long press toggles the reader menu: the same hold that opens it - // closes it. The SDK fires the long event once per hold, so the opening hold - // (still down as the menu appears) does not immediately re-close it, only a - // fresh press-and-hold does. - if (mappedInput.wasReaderMenuHold()) { - finishCancelled(); - return; - } - - // Touch goes through the FreeInkApp: render() registered the row hit rects; - // route the snapshot and let onRowEvent dispatch. - if (uiReady) { - const fui::InputSnapshot snap = touchSnapshotFrom(mappedInput); - if (snap.touchPressed || snap.touchReleased) { - const auto event = app.route(snap); - // No pressed-state repaint: the render it triggers would drop a slow - // tap's release inside the uiReady window (tap-to-activate needed two - // taps), and it costs a second e-ink refresh per tap. - if (app.invalidated()) requestUpdate(); - if (event) return; // dispatched to onRowEvent - } - } - - // Swipes scroll the viewport; the selection stays put (it may scroll - // off-screen) and button navigation pulls the view back to it. - const int menuCount = static_cast(activeMenuItems().size()); - const auto swipe = mappedInput.wasSwipe(); - if (swipe == MappedInputManager::SwipeDir::Up || swipe == MappedInputManager::SwipeDir::Down) { - const int delta = swipe == MappedInputManager::SwipeDir::Up ? visibleRows : -visibleRows; - const int next = scrollListBy(topIndex, delta, visibleRows, menuCount); - if (next != topIndex) { - topIndex = next; - requestUpdate(); - } - return; - } - - const auto moveSelection = [this, menuCount](const int index) { - selectedIndex = index - 1; - if (selectedIndex >= 0) topIndex = followListSelection(selectedIndex, topIndex, visibleRows, menuCount); - requestUpdate(); - }; - buttonNavigator.onNextRelease([this, menuCount, &moveSelection] { - moveSelection(ButtonNavigator::nextIndex(selectedIndex + 1, menuCount + 1)); - }); - buttonNavigator.onPreviousRelease([this, menuCount, &moveSelection] { - moveSelection(ButtonNavigator::previousIndex(selectedIndex + 1, menuCount + 1)); - }); - buttonNavigator.onNextContinuous([this] { moveActiveTab(true); }); - buttonNavigator.onPreviousContinuous([this] { moveActiveTab(false); }); - - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - activateSelectedItem(); - return; - } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - if (selectedIndex >= 0) { - focusTabRow(); - requestUpdate(); - return; - } - finishCancelled(); - return; - } -} - -void EpubReaderMenuActivity::menuScreen(UiApp::ScreenType& screen, void* user) { - static_cast(user)->buildMenuScreen(screen); -} - -void EpubReaderMenuActivity::buildMenuScreen(UiApp::ScreenType& screen) { - const auto& metrics = UITheme::getInstance().getMetrics(); - const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, !mappedInput.hasTouch(), false); - const int tabBarHeight = readerMenuTabBarHeight(metrics.tabBarHeight, mappedInput.hasTouch()); -#if CROSSINK_APP_CAP_TOUCH - // cppcheck-suppress knownConditionTrueFalse ; compile-time false on Sticky (touch, no frontlight) - const bool tabsAtBottom = readerMenuTabsAtBottom(mappedInput); - // Sticky has touch but no frontlight, so this is compile-time false there; - // X4 Pro still evaluates the runtime placement check. - const int contentTop = safe.y + metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + - metrics.tabBarHeight + - // cppcheck-suppress knownConditionTrueFalse - (tabsAtBottom ? 0 : tabBarHeight) + metrics.verticalSpacing; - const int contentBottom = renderer.getScreenHeight() - (safe.y + safe.height) + - // cppcheck-suppress knownConditionTrueFalse - (tabsAtBottom ? tabBarHeight + metrics.verticalSpacing : 0); -#else - const int contentTop = safe.y + metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + - metrics.tabBarHeight + tabBarHeight + metrics.verticalSpacing; - const int contentBottom = renderer.getScreenHeight() - (safe.y + safe.height); -#endif - // The legacy header, progress band, and icon tabs remain outside the app; - // FreeInkUI owns the scalable list between them. - screen.setContentMargin(fui::Insets{static_cast(contentTop), - static_cast(renderer.getScreenWidth() - (safe.x + safe.width)), - static_cast(contentBottom), static_cast(safe.x)}); - - const auto& activeItems = activeMenuItems(); - std::vector values(activeItems.size()); - std::vector items; - items.reserve(activeItems.size()); - for (size_t i = 0; i < activeItems.size(); i++) { - const auto& menuItem = activeItems[i]; - fui::ListItem item; - item.label = I18N.get(menuItem.labelId); - if (menuItem.action == MenuAction::ROTATE_SCREEN) { - item.value = I18N.get(orientationLabels[pendingOrientation]); - } else if (menuItem.action == MenuAction::AUTO_PAGE_TURN) { - if (autoPageTurnActive) values[i] = std::to_string(autoPageTurnIntervalSeconds); - item.value = values[i].empty() ? nullptr : values[i].c_str(); - } - item.actionValue = static_cast(items.size()); - items.push_back(item); - } - - fui::ListProps props; - props.items = items.data(); - props.count = static_cast(items.size()); - props.selectedIndex = static_cast(selectedIndex); - props.action = ACTION_ROW; - props.inputMask = fui::InputTouch; // physical buttons stay in loop() - props.valueInset = 8; // air between the value and the row edge - props.labelText = screen.theme().bodyText; - props.labelText.maxLines = 2; - const auto rows = configureUiList(props, screen.theme(), screen.body()); - visibleRows = rows > 0 ? rows : 1; - topIndex = scrollListBy(topIndex, 0, visibleRows, static_cast(activeItems.size())); // clamp to range - props.topIndex = static_cast(topIndex); - screen.list(props); -} - -void EpubReaderMenuActivity::render(RenderLock&&) { - if (optionPopup.processRender(renderer, mappedInput)) return; - - renderer.clearScreen(); - - auto metrics = UITheme::getInstance().getMetrics(); - const bool hasTouch = mappedInput.hasTouch(); - Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, !hasTouch, false); - const int tabBarHeight = readerMenuTabBarHeight(metrics.tabBarHeight, hasTouch); -#if CROSSINK_APP_CAP_TOUCH - // cppcheck-suppress knownConditionTrueFalse ; compile-time false on Sticky (touch, no frontlight) - const bool tabsAtBottom = readerMenuTabsAtBottom(mappedInput); -#endif - - // The menu is a system screen, not reading content: its status indicators - // stay visible unless their setting is Hide Always. - const Rect headerRect = readerMenuHeaderRect(renderer, mappedInput); - if (mappedInput.hasTouchHardware()) { - const Rect homeRect = readerMenuHeaderActionRect(headerRect, metrics); - const Rect homeTouchRect = readerMenuHeaderActionTouchRect(headerRect, homeRect); - const int titleRightReserve = headerRect.x + headerRect.width - homeRect.x + headerActionGap; - TouchHeaderBackButton::draw(renderer, uiTarget, headerRect, title.c_str(), true, titleRightReserve); - TouchRegistry::getInstance().add(homeTouchRect, static_cast(TOUCH_HOME_ICON_INDEX), TouchRegistry::Tab); - drawSdkIcon(uiTarget, icon_home_24, homeRect.x + (homeRect.width - tabIconSize) / 2, - homeRect.y + (homeRect.height - tabIconSize) / 2); - } else { - GUI.drawHeader(renderer, headerRect, title.c_str()); - } - - // Progress summary - std::string progressLine; - if (totalPages > 0) { - progressLine = std::string(tr(STR_CHAPTER_PREFIX)) + std::to_string(currentPage) + "/" + - std::to_string(totalPages) + std::string(tr(STR_PAGES_SEPARATOR)); - } - progressLine += std::string(tr(STR_BOOK_PREFIX)) + std::to_string(bookProgressPercent) + "%"; - GUI.drawSubHeader(renderer, - Rect{screen.x, screen.y + metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput), - screen.width, metrics.tabBarHeight}, - progressLine.c_str()); - - const int topTabBarY = - screen.y + metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + metrics.tabBarHeight; -#if CROSSINK_APP_CAP_TOUCH - // cppcheck-suppress knownConditionTrueFalse - const int tabBarY = tabsAtBottom ? screen.y + screen.height - tabBarHeight : topTabBarY; -#else - const int tabBarY = topTabBarY; -#endif - const Rect tabRect{screen.x, tabBarY, screen.width, tabBarHeight}; -#if CROSSINK_APP_CAP_TOUCH - drawIconTabBar(tabRect, !tabsAtBottom); -#else - drawIconTabBar(tabRect, true); -#endif - - uiReady = false; - app.render(); - uiReady = true; - - const auto confirmLabel = selectedIndex < 0 ? tr(STR_NEXT_FIELD) : tr(STR_SELECT); - const auto labels = - mappedInput.mapLabels(mappedInput.withBackArrow(tr(STR_BACK)), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4, true); - - renderer.displayBuffer(); -} diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h deleted file mode 100644 index 9d6af8d5f29..00000000000 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ /dev/null @@ -1,135 +0,0 @@ -#pragma once -#include -#include -#include -#include - -#include -#include -#include -#include - -#include "ControlsOptionsActivity.h" -#include "EpubReaderMenuModel.h" -#include "ReaderOptionsActivity.h" -#include "activities/Activity.h" -#include "components/OptionPopup.h" -#include "util/ButtonNavigator.h" - -struct Rect; - -class EpubReaderMenuActivity final : public Activity { - public: - using MenuAction = EpubReaderMenuAction; - - explicit EpubReaderMenuActivity( - GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, const int currentPage, - const int totalPages, const int bookProgressPercent, const uint8_t currentOrientation, const bool hasFootnotes, - const bool hasDictionary, const bool hasBookmarks, const bool hasClippings, const bool isCurrentPageBookmarked, - const bool isBookCompleted, const bool autoPageTurnActive = false, const uint16_t autoPageTurnIntervalSeconds = 0, - const bool showReadingPaceReset = false, - ReaderOptionsActivity::SaveSettingsCallback saveReaderSettingsCallback = nullptr, - void* saveReaderSettingsContext = nullptr, - ReaderOptionsActivity::SaveGlobalSettingsCallback saveGlobalSettingsCallback = nullptr, - void* saveGlobalSettingsContext = nullptr, - ReaderOptionsActivity::GlobalSettingsEditCallback beginGlobalSettingsEditCallback = nullptr, - void* beginGlobalSettingsEditContext = nullptr, uint32_t stableCurrentPage = 0, uint32_t stablePageCount = 0, - ReaderOptionsActivity::GlobalSettingsEditCallback endGlobalSettingsEditCallback = nullptr, - void* endGlobalSettingsEditContext = nullptr, const char* dictionaryFontFamilyName = nullptr, - uint8_t dictionaryFontPointSize = 0, bool hasDictionaryFontOverride = false, - ReaderOptionsActivity::DictionaryFontChangedCallback dictionaryFontChangedCallback = nullptr, - void* dictionaryFontChangedContext = nullptr); - - void onEnter() override; - void onExit() override; - void loop() override; - void render(RenderLock&&) override; - bool isReaderActivity() const override { return true; } - bool allowFrontlightPanelGesture() const override { return false; } - bool allowPowerAsConfirmInReaderMode() const override { return true; } - bool allowGlobalHomeGesture() const override { return false; } - - private: - struct MenuItem { - MenuAction action; - StrId labelId; - }; - - enum class MenuTab : uint8_t { Main = 0, Bookmarks = 1, Settings = 2 }; - static constexpr size_t MAIN_TAB_INDEX = 0; - static constexpr size_t BOOKMARKS_TAB_INDEX = 1; - static constexpr size_t SETTINGS_TAB_INDEX = 2; - static constexpr size_t MENU_TAB_COUNT = 3; - static constexpr size_t TOUCH_LOCK_ICON_INDEX = MENU_TAB_COUNT; - static constexpr size_t TOUCH_HOME_ICON_INDEX = MENU_TAB_COUNT + 1; - static constexpr size_t TOUCH_ICON_COUNT = MENU_TAB_COUNT + 1; - using TabMenuItems = std::array, MENU_TAB_COUNT>; - - static TabMenuItems buildMenuItems(bool hasFootnotes, bool hasBookmarks, bool hasClippings, - bool isCurrentPageBookmarked, bool isBookCompleted, bool showReadingPaceReset, - bool hasDictionary, bool hasStablePageNumbers); - [[nodiscard]] const std::vector& activeMenuItems() const; - [[nodiscard]] size_t activeTabIndex() const { return static_cast(activeTab); } - void cycleActiveTab(); - void moveActiveTab(bool forward); - void focusTabRow(); - void finishCancelled(); - bool activateSelectedItem(); - bool handleTouchInput(); - void drawIconTabBar(Rect rect, bool drawBottomBorder); - static void dictionaryFontChangedForMenu(void* ctx, const char* familyName, uint8_t pointSize); - - // FreeInkApp hosts the menu list (themed rows, touch routing); the header - // stays on GUI.drawHeader for the battery indicator, and OptionPopup keeps - // its legacy overlay rendering. - using UiApp = freeink::ui::FreeInkApp<20, 4>; - - static void menuScreen(UiApp::ScreenType& screen, void* user); - static void onRowEvent(const freeink::ui::ActionEvent& event, void* user); - void buildMenuScreen(UiApp::ScreenType& screen); - - // Fixed menu layout, except for rows tied to toggles inside this menu. - TabMenuItems menuItems; - - int selectedIndex = -1; - MenuTab activeTab = MenuTab::Main; - - ButtonNavigator buttonNavigator; - OptionPopup optionPopup; - std::string title = "Reader Menu"; - uint8_t pendingOrientation = 0; - const std::vector orientationLabels = {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, - StrId::STR_LANDSCAPE_CCW}; - int currentPage = 0; - int totalPages = 0; - int bookProgressPercent = 0; - bool autoPageTurnActive = false; - uint16_t autoPageTurnIntervalSeconds = 0; - ReaderOptionsActivity::SaveSettingsCallback saveReaderSettingsCallback = nullptr; - void* saveReaderSettingsContext = nullptr; - ReaderOptionsActivity::SaveGlobalSettingsCallback saveGlobalSettingsCallback = nullptr; - void* saveGlobalSettingsContext = nullptr; - ReaderOptionsActivity::GlobalSettingsEditCallback beginGlobalSettingsEditCallback = nullptr; - void* beginGlobalSettingsEditContext = nullptr; - uint32_t stableCurrentPage = 0; - uint32_t stablePageCount = 0; - ReaderOptionsActivity::GlobalSettingsEditCallback endGlobalSettingsEditCallback = nullptr; - void* endGlobalSettingsEditContext = nullptr; - char dictionaryFontFamilyName[64] = ""; - uint8_t dictionaryFontPointSize = 0; - bool hasDictionaryFontOverride = false; - ReaderOptionsActivity::DictionaryFontChangedCallback dictionaryFontChangedCallback = nullptr; - void* dictionaryFontChangedContext = nullptr; - bool settingsChanged = false; - ReaderSettingsChangeMask changeMask = ReaderSettingsChangeMask::None; - - MenuResult makeMenuResult(int action) const; - - freeink::ui::GfxRendererTarget uiTarget; // must precede `app`: the app holds a reference to it - UiApp app; - // render() rebuilds the app's interaction table; loop() only routes touch - // snapshots against it while this is true (the two run on different tasks). - std::atomic uiReady{false}; - int visibleRows = 1; // rows per page at the current scale; set by the screen builder - int topIndex = 0; // viewport scroll position, decoupled from the selection -}; diff --git a/src/activities/reader/EpubReaderMenuModel.h b/src/activities/reader/EpubReaderMenuModel.h index 7621119c840..e376aa85b2b 100644 --- a/src/activities/reader/EpubReaderMenuModel.h +++ b/src/activities/reader/EpubReaderMenuModel.h @@ -39,6 +39,11 @@ enum class EpubReaderMenuAction : uint8_t { enum class ReaderDrawerTab : uint8_t { Font = 0, Layout = 1, More = 2, Location = 3, Settings = 4, Count }; constexpr size_t READER_DRAWER_TAB_COUNT = static_cast(ReaderDrawerTab::Count); +constexpr ReaderDrawerTab adjacentReaderDrawerTab(const ReaderDrawerTab tab, const bool forward) { + const size_t current = static_cast(tab); + return static_cast((current + (forward ? 1 : READER_DRAWER_TAB_COUNT - 1)) % + READER_DRAWER_TAB_COUNT); +} constexpr uint16_t READER_AUTO_PAGE_TURN_MIN_SECONDS = 5; constexpr uint16_t READER_AUTO_PAGE_TURN_MAX_SECONDS = 120; @@ -58,6 +63,35 @@ enum class ReaderDrawerPane : uint8_t { TtfRendering, }; +enum class ReaderButtonSliderInput : uint8_t { Next, Previous, Confirm, Back }; +enum class ReaderButtonSliderAction : uint8_t { None, Increase, Decrease, LeavePane }; + +struct ReaderButtonSliderState { + uint8_t focus = 0; + bool editing = false; +}; + +constexpr ReaderButtonSliderAction readerButtonSliderInput(ReaderButtonSliderState& state, + const ReaderButtonSliderInput input) { + if (input == ReaderButtonSliderInput::Back) { + if (state.editing) { + state.editing = false; + return ReaderButtonSliderAction::None; + } + return ReaderButtonSliderAction::LeavePane; + } + if (input == ReaderButtonSliderInput::Confirm) { + state.editing = !state.editing; + return ReaderButtonSliderAction::None; + } + if (state.editing) { + return input == ReaderButtonSliderInput::Next ? ReaderButtonSliderAction::Increase + : ReaderButtonSliderAction::Decrease; + } + state.focus = input == ReaderButtonSliderInput::Next ? 1 : 0; + return ReaderButtonSliderAction::None; +} + enum class ReaderDrawerCatalogItem : uint8_t { ReaderFont, TtfRendering, @@ -110,6 +144,10 @@ enum class ReaderDrawerCatalogItem : uint8_t { TtfStemDarkening, TtfReset, ResetBookReaderSettings, + ReadingStats, + SyncProgress, + NearbyPositionSync, + SendNearbyBook, }; struct ReaderDrawerAvailability { @@ -119,6 +157,7 @@ struct ReaderDrawerAvailability { bool hasClippings = false; bool showReadingPaceReset = false; bool hasStablePageNumbers = false; + bool buttonDevice = false; }; struct ReaderDrawerTabCatalog { @@ -160,6 +199,7 @@ constexpr ReaderDrawerCatalog makeReaderDrawerCatalog(const ReaderDrawerAvailabi more.add(ReaderDrawerCatalogItem::GoToPercent); if (available.hasStablePageNumbers) more.add(ReaderDrawerCatalogItem::GoToStablePage); more.add(ReaderDrawerCatalogItem::AutoPageTurn); + if (available.buttonDevice) more.add(ReaderDrawerCatalogItem::ReadingStats); if (available.hasFootnotes) more.add(ReaderDrawerCatalogItem::Footnotes); auto& location = catalog[static_cast(ReaderDrawerTab::Location)]; @@ -170,6 +210,11 @@ constexpr ReaderDrawerCatalog makeReaderDrawerCatalog(const ReaderDrawerAvailabi } location.add(ReaderDrawerCatalogItem::SaveClipping); if (available.hasClippings) location.add(ReaderDrawerCatalogItem::ViewClippings); + if (available.buttonDevice) { + location.add(ReaderDrawerCatalogItem::SyncProgress); + location.add(ReaderDrawerCatalogItem::NearbyPositionSync); + location.add(ReaderDrawerCatalogItem::SendNearbyBook); + } location.add(ReaderDrawerCatalogItem::Screenshot); location.add(ReaderDrawerCatalogItem::DisplayQr); @@ -187,15 +232,21 @@ constexpr ReaderDrawerCatalog makeReaderDrawerCatalog(const ReaderDrawerAvailabi return catalog; } -constexpr bool shouldReopenTouchReaderDrawer(const bool reopenDrawer, const bool hasTouchHardware) { - return reopenDrawer && hasTouchHardware; -} - constexpr bool readerDrawerStepChangesSettings(const ReaderDrawerPane pane) { return pane == ReaderDrawerPane::Spacing || pane == ReaderDrawerPane::Margins || pane == ReaderDrawerPane::AutoPageTurn; } +// Only settings with an existing live text preview reserve sample space. +constexpr bool readerDrawerShowsSamplePreview(const ReaderDrawerPane pane, const ReaderDrawerTab tab, + const ReaderDrawerCatalogItem option) { + return (pane == ReaderDrawerPane::Root && tab == ReaderDrawerTab::Font) || pane == ReaderDrawerPane::ReaderFont || + pane == ReaderDrawerPane::FontFamily || pane == ReaderDrawerPane::Spacing || + pane == ReaderDrawerPane::Margins || + (pane == ReaderDrawerPane::EnumOptions && + (option == ReaderDrawerCatalogItem::FontSize || option == ReaderDrawerCatalogItem::Alignment)); +} + constexpr bool readerDrawerSliderPreviewsText(const ReaderDrawerPane pane) { return pane == ReaderDrawerPane::Spacing || pane == ReaderDrawerPane::Margins; } @@ -277,6 +328,12 @@ struct ReaderSettingsDraft { uint8_t indexingMethod = 0; }; +inline void restoreReaderDraftFont(ReaderSettingsDraft& draft, const ReaderSettingsDraft& lastGood) { + draft.fontFamily = lastGood.fontFamily; + draft.readerFontPointSize = lastGood.readerFontPointSize; + draft.sdFontFamilyName = lastGood.sdFontFamilyName; +} + enum class ReaderSettingsChangeMask : uint8_t { None = 0, Preview = 1 << 0, diff --git a/src/activities/reader/EpubReaderPercentSelectionActivity.cpp b/src/activities/reader/EpubReaderPercentSelectionActivity.cpp index 3c8a6dee495..ae4c32a2dba 100644 --- a/src/activities/reader/EpubReaderPercentSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderPercentSelectionActivity.cpp @@ -536,8 +536,10 @@ void EpubReaderPercentSelectionActivity::render(RenderLock&&) { uiReady = true; // Button hints follow the current front button layout and auto-hide on touch devices. - const auto labels = mappedInput.mapLabels(mappedInput.withBackArrow(tr(STR_BACK)), tr(STR_SELECT), tr(STR_DIR_LEFT), - tr(STR_DIR_RIGHT)); + const bool percentSlider = mode == Mode::Percent && !isKeypadVisible(); + const auto labels = + mappedInput.mapLabels(mappedInput.withBackArrow(tr(STR_BACK)), tr(STR_SELECT), + percentSlider ? "-1" : tr(STR_DIR_LEFT), percentSlider ? "+1" : tr(STR_DIR_RIGHT)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4, true); renderer.displayBuffer(); diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index b3f7b335bbb..11b669d8890 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -21,12 +21,8 @@ constexpr unsigned long GO_HOME_MS = 1000; // same gesture cannot drift apart between the two lists. constexpr unsigned long DELETE_HOLD_MS = 1000; constexpr uint8_t STATUS_BAR_TEXT_PADDING = 3; -// Gap between the top clock status bar band and the first line of book text. -// Signed so negative values pull the text up toward the clock (unsigned would wrap -// a negative to a huge positive). Note the book-text top margin is -// std::max(screenMarginVertical, reservedClockHeight + TOP_CLOCK_TEXT_PADDING), so this only -// bites once reservedClockHeight + padding drops below the vertical-margin setting. -constexpr int8_t TOP_CLOCK_TEXT_PADDING = 0; +// Gap between the top reader bar and the first line of book text. +constexpr int8_t TOP_STATUS_BAR_TEXT_PADDING = 0; inline GfxRenderer::Orientation toRendererOrientation(const uint8_t orientation) { switch (orientation) { @@ -52,7 +48,7 @@ inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) { // stays centered in every orientation instead of sitting at a fixed portrait offset. inline int messageCenterY(const GfxRenderer& renderer) { return renderer.getScreenHeight() / 2; } -inline bool shouldShowTopClockStatusBar() { return halClock.isAvailable() && SETTINGS.shouldShowClockInReader(); } +inline bool shouldShowTopStatusBar() { return UITheme::getReaderStatusBarHeight(ReaderStatusBarPosition::Top) > 0; } // Night Mode is applied by the display after normal-polarity reader content is // rendered. Keep this compatibility helper for existing reader call sites. @@ -62,31 +58,30 @@ inline uint8_t readerBackgroundColor() { return readerDarkModeEnabled() ? 0x00 : inline bool readerForegroundBlack() { return true; } -inline int getTopClockStatusBarHeight() { - if (!shouldShowTopClockStatusBar()) { +inline int getTopStatusBarReservedHeight(const GfxRenderer& renderer) { + const int statusBarHeight = UITheme::getReaderStatusBarHeight(ReaderStatusBarPosition::Top); + if (statusBarHeight <= 0) { return 0; } - const auto& metrics = UITheme::getInstance().getMetrics(); - return std::max(UITheme::getStatusBarHeight(), metrics.statusBarVerticalMargin); + int top, right, bottom, left; + renderer.getOrientedViewableTRBL(&top, &right, &bottom, &left); + // Reader layout already includes the bezel margin; reserve only the remainder. + return std::max(0, UITheme::getTopStatusBarY(renderer) + statusBarHeight - top); } -inline int getTopClockStatusBarReservedHeight(const GfxRenderer& renderer) { - const int statusBarHeight = getTopClockStatusBarHeight(); - if (statusBarHeight <= 0) { - return 0; - } - - return UITheme::getInstance().getMetrics().topPadding + UITheme::getTopStatusBarInset(renderer) + statusBarHeight; +inline bool bottomStatusBarHasTextLane() { + return SETTINGS.readerStatusBar(ReaderStatusBarPosition::Bottom).hasTextItems(halClock.isAvailable()); } inline int getReaderFooterReservedHeight(const bool automaticPageTurnActive) { - const uint8_t statusBarHeight = UITheme::getInstance().getStatusBarHeight(); - if (automaticPageTurnActive && - (statusBarHeight == 0 || statusBarHeight == UITheme::getInstance().getProgressBarHeight())) { + const int statusBarHeight = UITheme::getInstance().getStatusBarHeight(); + if (automaticPageTurnActive && !bottomStatusBarHasTextLane()) { + const int autoTurnBarHeight = + readerStatusBarTotalHeight(ReaderStatusBarPosition::Bottom, true, UITheme::getProgressBarHeight(), + UITheme::getInstance().getMetrics().statusBarVerticalMargin); return std::max(static_cast(SETTINGS.screenMarginVertical), - static_cast(statusBarHeight + UITheme::getInstance().getMetrics().statusBarVerticalMargin + - STATUS_BAR_TEXT_PADDING)); + std::max(statusBarHeight, autoTurnBarHeight) + STATUS_BAR_TEXT_PADDING); } return std::max(static_cast(SETTINGS.screenMarginVertical), static_cast(statusBarHeight + STATUS_BAR_TEXT_PADDING)); @@ -180,10 +175,8 @@ inline bool isBottomStatusBarTap(const GfxRenderer& renderer, const int y, const } inline bool isTopStatusBarTap(const GfxRenderer& renderer, const int y, const int statusBarHeight) { - int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; - renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, - &orientedMarginLeft); - return ReaderStatusBarTapTarget::containsTop(y, renderer.getScreenHeight(), orientedMarginTop, statusBarHeight); + return ReaderStatusBarTapTarget::containsTop(y, renderer.getScreenHeight(), UITheme::getTopStatusBarY(renderer), + statusBarHeight); } // Reader menu opens on its board-specific vertical swipe anywhere on the open diff --git a/src/activities/reader/TouchReaderPreviewModel.h b/src/activities/reader/TouchReaderPreviewModel.h index 6a34e7d91de..d37dfccbdd7 100644 --- a/src/activities/reader/TouchReaderPreviewModel.h +++ b/src/activities/reader/TouchReaderPreviewModel.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -10,11 +11,13 @@ #include #include -class TouchReaderPreviewModel { +template +class ReaderPreviewModel { public: - static constexpr size_t TEXT_CAPACITY = 8U * 1024U; - static constexpr size_t WORD_CAPACITY = 256; - static constexpr size_t LINE_CAPACITY = 128; + static constexpr size_t TEXT_CAPACITY = TextCapacity; + static constexpr size_t WORD_CAPACITY = WordCapacity; + static constexpr size_t LINE_CAPACITY = LineCapacity; + static constexpr bool RETAINS_SOURCE_BLOCKS = KeepSourceBlocks; bool capture(const Page& page, const GfxRenderer& renderer, const int fontId, const uint8_t lineHeightPercent, const int xOffset = 0, const int yOffset = 0) { @@ -47,7 +50,7 @@ class TouchReaderPreviewModel { Line& line = lines[lineCount++]; line.x = pageLine.xPos; line.y = pageLine.yPos; - line.sourceBlock = block; + if constexpr (KeepSourceBlocks) line.sourceBlock = block; line.firstWord = wordCount; line.wordCount = block->wordCount(); line.style = block->getBlockStyle(); @@ -106,10 +109,41 @@ class TouchReaderPreviewModel { return hasBaseline && wordCount > 0; } + // Plain sample text needs no Page, TextBlock ownership, or source geometry. + // Split once into the model's bounded storage; rendering reuses its metrics. + bool captureParagraph(const char* paragraph) { + clear(); + if (!paragraph || std::strlen(paragraph) >= text.size()) return false; + std::strcpy(text.data(), paragraph); + char* cursor = text.data(); + while (*cursor) { + while (*cursor == ' ') ++cursor; + if (!*cursor) break; + if (wordCount == words.size()) { + clear(); + return false; + } + Word& word = words[wordCount]; + word = {}; + word.textOffset = static_cast(cursor - text.data()); + word.hasSpaceBefore = wordCount > 0; + word.mayBreakBefore = wordCount > 0; + ++wordCount; + while (*cursor && *cursor != ' ') ++cursor; + if (*cursor) *cursor++ = '\0'; + } + if (wordCount == 0) return false; + lines[0] = {}; + lines[0].wordCount = wordCount; + lineCount = 1; + hasBaseline = true; + return true; + } + void renderText(const GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset, const int contentWidth, const uint8_t lineHeightPercent, const uint8_t wordSpacing, const uint8_t paragraphAlignment, const bool focusReadingEnabled, const bool guideReadingEnabled, - const bool foregroundBlack) const { + const bool foregroundBlack, const int bottom = std::numeric_limits::max()) const { if (!valid()) return; const int currentLineHeight = std::max(1, (renderer.getLineHeight(fontId) * lineHeightPercent + 50) / 100); int y = firstLineY + yOffset; @@ -135,6 +169,7 @@ class TouchReaderPreviewModel { guideReadingEnabled); prepareLineBreaks(firstWord, paragraphWordEnd, availableWidth, previewFirstLineIndent(line, alignment)); while (wordIndex < paragraphWordEnd) { + if (bottom != std::numeric_limits::max() && y + renderer.getTextHeight(fontId) > bottom) return; const int firstLineIndent = firstPreviewLine ? previewFirstLineIndent(line, alignment) : 0; const uint16_t lineEnd = nextBreak[wordIndex]; renderReflowedLine(renderer, fontId, wordIndex, lineEnd, y, availableLeft, availableWidth, firstLineIndent, @@ -160,9 +195,11 @@ class TouchReaderPreviewModel { // cppcheck-suppress constParameterReference void renderSource(GfxRenderer& renderer, const int fontId, const bool foregroundBlack) const { if (!valid()) return; - for (size_t i = 0; i < lineCount; ++i) { - const auto& line = lines[i]; - line.sourceBlock->render(renderer, fontId, sourceXOffset + line.x, sourceYOffset + line.y, foregroundBlack); + if constexpr (KeepSourceBlocks) { + for (size_t i = 0; i < lineCount; ++i) { + const auto& line = lines[i]; + line.sourceBlock->render(renderer, fontId, sourceXOffset + line.x, sourceYOffset + line.y, foregroundBlack); + } } } @@ -523,7 +560,9 @@ class TouchReaderPreviewModel { } void clear() { - for (size_t i = 0; i < lineCount; ++i) lines[i].sourceBlock.reset(); + if constexpr (KeepSourceBlocks) { + for (size_t i = 0; i < lineCount; ++i) lines[i].sourceBlock.reset(); + } textSize = 0; wordCount = 0; lineCount = 0; @@ -532,3 +571,19 @@ class TouchReaderPreviewModel { hasBaseline = false; } }; + +// Deliberately fixed Latin sample: font/layout test content, not a UI label. +inline constexpr char READER_PREVIEW_PARAGRAPH[] = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore " + "magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo " + "consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. " + "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."; +using SampleReaderPreviewModel = ReaderPreviewModel; +static_assert(sizeof(SampleReaderPreviewModel) <= 3U * 1024U, "Sample preview exceeds its C3 budget"); + +using TouchReaderPreviewModel = ReaderPreviewModel<8U * 1024U, 256, 128, true>; +#if CROSSINK_APP_READER_SAMPLE_PREVIEW +using EpubReaderPreviewModel = SampleReaderPreviewModel; +#else +using EpubReaderPreviewModel = TouchReaderPreviewModel; +#endif diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index e8d57cda84b..8abb4817e97 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -207,7 +207,9 @@ void TxtReaderActivity::loop() { #endif const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); if (touch.tapped && - ReaderUtils::isBottomStatusBarTap(renderer, touch.y, UITheme::getInstance().getStatusBarHeight())) { + (ReaderUtils::isBottomStatusBarTap(renderer, touch.y, UITheme::getInstance().getStatusBarHeight()) || + ReaderUtils::isTopStatusBarTap(renderer, touch.y, + UITheme::getReaderStatusBarHeight(ReaderStatusBarPosition::Top)))) { if (SETTINGS.tapToHideStatusBar) { statusBarVisible = !statusBarVisible; requestUpdate(); @@ -236,6 +238,16 @@ void TxtReaderActivity::loop() { return; } + if (SETTINGS.longPressMenuAction == CrossPointSettings::LONG_MENU_LIBRARY && + mappedInput.getHeldTime() >= LONG_PRESS_MENU_MS && + (mappedInput.isPressed(MappedInputManager::Button::Confirm) || + mappedInput.wasReleased(MappedInputManager::Button::Confirm))) { + longPressMenuHandled = mappedInput.isPressed(MappedInputManager::Button::Confirm); + mappedInput.suppressNextConfirmRelease(); + activityManager.goToLibrary(); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { openReaderMenu(); return; @@ -478,6 +490,7 @@ bool TxtReaderActivity::supportsQuickAction(const CrossPointSettings::SHORT_PWRB case CrossPointSettings::SHORT_PWRBTN::TOGGLE_DARK_MODE: case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FONT: case CrossPointSettings::SHORT_PWRBTN::FILE_BROWSER: + case CrossPointSettings::SHORT_PWRBTN::LIBRARY: case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FRONTLIGHT: case CrossPointSettings::SHORT_PWRBTN::TOGGLE_TOUCHSCREEN: return true; @@ -518,6 +531,9 @@ bool TxtReaderActivity::executeReaderShortcutAction(const CrossPointSettings::SH case CrossPointSettings::SHORT_PWRBTN::FILE_BROWSER: activityManager.goToFileBrowser(txt ? txt->getPath() : ""); return true; + case CrossPointSettings::SHORT_PWRBTN::LIBRARY: + activityManager.goToLibrary(); + return true; case CrossPointSettings::SHORT_PWRBTN::TOGGLE_HOME_BUTTON_IN_READER: toggleHomeButtonInReader(); return true; @@ -595,6 +611,9 @@ bool TxtReaderActivity::executeLongPressBackAction() { case CrossPointSettings::LONG_PRESS_MENU_ACTION::LONG_MENU_FILE_BROWSER: activityManager.goToFileBrowser(txt ? txt->getPath() : ""); return true; + case CrossPointSettings::LONG_PRESS_MENU_ACTION::LONG_MENU_LIBRARY: + activityManager.goToLibrary(); + return true; case CrossPointSettings::LONG_PRESS_MENU_ACTION::LONG_MENU_CREATE_CLIPPING: return false; default: @@ -634,22 +653,23 @@ void TxtReaderActivity::initializeReader() { cachedVerticalMargin = SETTINGS.screenMarginVertical; cachedHorizontalMargin = SETTINGS.screenMarginHorizontal; cachedParagraphAlignment = SETTINGS.paragraphAlignment; + cachedTopStatusBarHeight = ReaderUtils::getTopStatusBarReservedHeight(renderer); + cachedBottomStatusBarHeight = UITheme::getInstance().getStatusBarHeight(); // Calculate viewport dimensions renderer.getOrientedViewableTRBL(&cachedOrientedMarginTop, &cachedOrientedMarginRight, &cachedOrientedMarginBottom, &cachedOrientedMarginLeft); cachedOrientedMarginLeft += cachedHorizontalMargin; cachedOrientedMarginRight += cachedHorizontalMargin; - const int topStatusBarReservedHeight = ReaderUtils::getTopClockStatusBarReservedHeight(renderer); + const int topStatusBarReservedHeight = cachedTopStatusBarHeight; if (topStatusBarReservedHeight > 0) { cachedOrientedMarginTop += std::max(static_cast(cachedVerticalMargin), - topStatusBarReservedHeight + ReaderUtils::TOP_CLOCK_TEXT_PADDING); + topStatusBarReservedHeight + ReaderUtils::TOP_STATUS_BAR_TEXT_PADDING); } else { cachedOrientedMarginTop += cachedVerticalMargin; } cachedOrientedMarginBottom += std::max( - cachedVerticalMargin, - static_cast(UITheme::getInstance().getStatusBarHeight() + ReaderUtils::STATUS_BAR_TEXT_PADDING)); + cachedVerticalMargin, static_cast(cachedBottomStatusBarHeight + ReaderUtils::STATUS_BAR_TEXT_PADDING)); viewportWidth = renderer.getScreenWidth() - cachedOrientedMarginLeft - cachedOrientedMarginRight; const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom; @@ -757,9 +777,26 @@ void TxtReaderActivity::render(RenderLock&&) { return; } + bool relayout = false; + size_t readingOffset = 0; + if (initialized && (cachedTopStatusBarHeight != ReaderUtils::getTopStatusBarReservedHeight(renderer) || + cachedBottomStatusBarHeight != UITheme::getInstance().getStatusBarHeight())) { + if (currentPage >= 0 && currentPage < static_cast(pageOffsets.size())) { + readingOffset = pageOffsets[currentPage]; + relayout = true; + } + if (!flushQueuedProgress()) LOG_ERR("TRS", "Failed to save progress before status bar relayout"); + initialized = false; + } + // Initialize reader if not done if (!initialized) { initializeReader(); + if (relayout && !pageOffsets.empty()) { + const auto nextPage = std::upper_bound(pageOffsets.begin(), pageOffsets.end(), readingOffset); + currentPage = std::max(0, static_cast(nextPage - pageOffsets.begin()) - 1); + if (!saveProgress(currentPage)) LOG_ERR("TRS", "Failed to save progress after status bar relayout"); + } } if (pageOffsets.empty()) { @@ -841,10 +878,6 @@ void TxtReaderActivity::renderPage() { // BW rendering renderLines(); renderStatusBar(); - if (statusBarVisible) { - GUI.drawTopStatusBarClock(renderer, UITheme::getInstance().getMetrics().topPadding, nullptr, true, 0, - ReaderUtils::readerDarkModeEnabled()); - } ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); @@ -860,12 +893,24 @@ void TxtReaderActivity::renderStatusBar() const { } const float progress = totalPages > 0 ? (currentPage + 1) * 100.0f / totalPages : 0; + const auto needsTitle = [](const ReaderStatusBarConfig& bar) { + return bar.contains(ReaderStatusBarItem::TitleBook) || bar.contains(ReaderStatusBarItem::TitleChapter); + }; std::string title; - if (SETTINGS.statusBarSpec().showsTitle()) { + if (needsTitle(SETTINGS.readerStatusBar(ReaderStatusBarPosition::Top)) || + needsTitle(SETTINGS.readerStatusBar(ReaderStatusBarPosition::Bottom))) { title = txt->getTitle(); } - GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title.c_str(), 0, 0, false, nullptr, - ReaderUtils::readerDarkModeEnabled()); + ReaderStatusBarContent content; + content.bookProgress = progress; + content.chapterPage = currentPage + 1; + content.chapterPageCount = totalPages; + content.bookTitle = title.c_str(); + // TXT has no chapter metadata; preserve its existing title fallback. + content.chapterTitle = content.bookTitle; + content.darkMode = ReaderUtils::readerDarkModeEnabled(); + GUI.drawReaderStatusBar(renderer, ReaderStatusBarPosition::Top, content); + GUI.drawReaderStatusBar(renderer, ReaderStatusBarPosition::Bottom, content); } bool TxtReaderActivity::getFrontlightPanelBookDetails(FrontlightPanelBookDetails& details) { @@ -945,6 +990,7 @@ void TxtReaderActivity::loadProgress() { currentPage = 0; } } + f.close(); } } @@ -1110,10 +1156,10 @@ bool TxtReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gfx renderer.getOrientedViewableTRBL(&marginTop, &marginRight, &marginBottom, &marginLeft); marginLeft += horizontalMargin; marginRight += horizontalMargin; - const int topStatusBarReservedHeight = ReaderUtils::getTopClockStatusBarReservedHeight(renderer); + const int topStatusBarReservedHeight = ReaderUtils::getTopStatusBarReservedHeight(renderer); if (topStatusBarReservedHeight > 0) { - marginTop += - std::max(static_cast(verticalMargin), topStatusBarReservedHeight + ReaderUtils::TOP_CLOCK_TEXT_PADDING); + marginTop += std::max(static_cast(verticalMargin), + topStatusBarReservedHeight + ReaderUtils::TOP_STATUS_BAR_TEXT_PADDING); } else { marginTop += verticalMargin; } diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 3d4fffdc126..dac988131ad 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -44,6 +44,8 @@ class TxtReaderActivity final : public Activity { uint8_t cachedVerticalMargin = 0; uint8_t cachedHorizontalMargin = 0; uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN; + int cachedTopStatusBarHeight = 0; + int cachedBottomStatusBarHeight = 0; int cachedOrientedMarginTop = 0; int cachedOrientedMarginRight = 0; int cachedOrientedMarginBottom = 0; diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 3d19d165cc7..607e0a9c101 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -208,13 +208,16 @@ void XtcReaderActivity::loop() { shortcutPreviousPagePending = false; const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); - const int statusBarHeight = UITheme::getInstance().getStatusBarHeight(); + const int bottomHeight = UITheme::getReaderStatusBarHeight(ReaderStatusBarPosition::Bottom); + const int topHeight = + SETTINGS.legacyXtcTopUsesBottom ? bottomHeight : UITheme::getReaderStatusBarHeight(ReaderStatusBarPosition::Top); const auto statusBarMode = static_cast(SETTINGS.xtcStatusBarMode); - const bool tappedStatusBar = - touch.tapped && ((statusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP && - ReaderUtils::isTopStatusBarTap(renderer, touch.y, statusBarHeight)) || - (statusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_BOTTOM && - ReaderUtils::isBottomStatusBarTap(renderer, touch.y, statusBarHeight))); + const bool tappedStatusBar = touch.tapped && (((statusBarMode == CrossPointSettings::XTC_STATUS_BAR_TOP || + statusBarMode == CrossPointSettings::XTC_STATUS_BAR_BOTH) && + ReaderUtils::isTopStatusBarTap(renderer, touch.y, topHeight)) || + ((statusBarMode == CrossPointSettings::XTC_STATUS_BAR_BOTTOM || + statusBarMode == CrossPointSettings::XTC_STATUS_BAR_BOTH) && + ReaderUtils::isBottomStatusBarTap(renderer, touch.y, bottomHeight))); if (tappedStatusBar) { if (SETTINGS.tapToHideStatusBar) { statusBarVisible = !statusBarVisible; @@ -322,6 +325,14 @@ void XtcReaderActivity::loop() { return; } + if (longPressMenuAction == CrossPointSettings::LONG_MENU_LIBRARY && mappedInput.getHeldTime() >= LONG_PRESS_MENU_MS && + (mappedInput.isPressed(MappedInputManager::Button::Confirm) || confirmReleased)) { + longPressMenuHandled = mappedInput.isPressed(MappedInputManager::Button::Confirm); + mappedInput.suppressNextConfirmRelease(); + activityManager.goToLibrary(); + return; + } + if (confirmReleased || ReaderUtils::isTouchMenuGesture(mappedInput)) { openReaderMenu(); return; @@ -745,13 +756,11 @@ void XtcReaderActivity::recordForwardPageTurn(const uint32_t seconds, const bool globalStats.totalPagesTurned++; } -bool XtcReaderActivity::formatTimeLeftLabel(char* buf, const size_t len, const uint32_t pageToRender) const { - if (!buf || len == 0 || !xtc || - SETTINGS.statusBarTimeLeft == CrossPointSettings::STATUS_BAR_TIME_LEFT::TIME_LEFT_HIDE) { +bool XtcReaderActivity::formatTimeLeftLabel(char* buf, const size_t len, const uint32_t pageToRender, + const bool bookEstimate) const { + if (!buf || len == 0 || !xtc) { return false; } - - const bool bookEstimate = SETTINGS.statusBarTimeLeft == CrossPointSettings::STATUS_BAR_TIME_LEFT::TIME_LEFT_BOOK; const auto pageInfo = getStatusBarInfo(pageToRender); const uint32_t current = bookEstimate ? pageToRender + 1U : static_cast(pageInfo.currentPage); const uint32_t total = bookEstimate ? xtc->getPageCount() : static_cast(pageInfo.pageCount); @@ -1020,6 +1029,7 @@ bool XtcReaderActivity::supportsQuickAction(const CrossPointSettings::SHORT_PWRB case CrossPointSettings::SHORT_PWRBTN::JOIN_NETWORK: case CrossPointSettings::SHORT_PWRBTN::CREATE_HOTSPOT: case CrossPointSettings::SHORT_PWRBTN::FILE_BROWSER: + case CrossPointSettings::SHORT_PWRBTN::LIBRARY: case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FRONTLIGHT: case CrossPointSettings::SHORT_PWRBTN::TOGGLE_TOUCHSCREEN: return true; @@ -1054,6 +1064,9 @@ bool XtcReaderActivity::executeReaderShortcutAction(const CrossPointSettings::SH case CrossPointSettings::SHORT_PWRBTN::FILE_BROWSER: activityManager.goToFileBrowser(xtc ? xtc->getPath() : ""); return true; + case CrossPointSettings::SHORT_PWRBTN::LIBRARY: + activityManager.goToLibrary(); + return true; case CrossPointSettings::SHORT_PWRBTN::TOGGLE_HOME_BUTTON_IN_READER: toggleHomeButtonInReader(); return true; @@ -1092,6 +1105,9 @@ bool XtcReaderActivity::executeLongPressBackAction() { case CrossPointSettings::LONG_PRESS_MENU_ACTION::LONG_MENU_FILE_BROWSER: activityManager.goToFileBrowser(xtc ? xtc->getPath() : ""); return true; + case CrossPointSettings::LONG_PRESS_MENU_ACTION::LONG_MENU_LIBRARY: + activityManager.goToLibrary(); + return true; case CrossPointSettings::LONG_PRESS_MENU_ACTION::LONG_MENU_CREATE_CLIPPING: return false; case CrossPointSettings::LONG_PRESS_MENU_ACTION::LONG_MENU_QUICK_LOCK: @@ -1153,12 +1169,11 @@ void XtcReaderActivity::render(RenderLock&&) { } } -XtcReaderActivity::StatusBarInfo XtcReaderActivity::getStatusBarInfo(const uint32_t pageToRender) const { - const auto statusBar = SETTINGS.statusBarSpec(); +XtcReaderActivity::StatusBarInfo XtcReaderActivity::getStatusBarInfo(const uint32_t pageToRender, + const bool includeTitle) const { const int bookPageCount = static_cast(xtc->getPageCount()); const int bookPage = static_cast(pageToRender) + 1; - std::string title = - SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE ? xtc->getTitle() : ""; + std::string title; if (!xtc->hasChapters()) { return StatusBarInfo{bookPage, bookPageCount, std::move(title)}; @@ -1169,25 +1184,28 @@ XtcReaderActivity::StatusBarInfo XtcReaderActivity::getStatusBarInfo(const uint3 return StatusBarInfo{bookPage, bookPageCount, std::move(title)}; } - if (statusBar.titleMode == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) { - title = chapter.name[0] == '\0' ? tr(STR_UNNAMED) : chapter.name; - } + if (includeTitle) title = chapter.name[0] == '\0' ? tr(STR_UNNAMED) : chapter.name; return StatusBarInfo{static_cast(pageToRender - chapter.startPage) + 1, static_cast(chapter.endPage - chapter.startPage) + 1, std::move(title)}; } -void XtcReaderActivity::renderStatusBarOverlay(const StatusBarOverlayPosition position, - const uint32_t pageToRender) const { - const bool drawBottom = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_BOTTOM && +void XtcReaderActivity::renderStatusBarOverlay(const StatusBarOverlayPosition position, const uint32_t pageToRender, + const bool drawContent) const { + const bool drawBottom = (SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_BOTTOM || + SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_BOTH) && position == StatusBarOverlayPosition::Bottom; - const bool drawTop = SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP && + const bool drawTop = (SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_TOP || + SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_BOTH) && position == StatusBarOverlayPosition::Top; if (!drawBottom && !drawTop) { return; } - const int statusBarHeight = UITheme::getInstance().getStatusBarHeight(); + const bool legacyTop = drawTop && SETTINGS.legacyXtcTopUsesBottom; + const auto displayedBar = drawTop ? ReaderStatusBarPosition::Top : ReaderStatusBarPosition::Bottom; + const auto configuredBar = xtcStatusBarConfigPosition(displayedBar, legacyTop); + const int statusBarHeight = UITheme::getReaderStatusBarHeight(configuredBar); if (statusBarHeight <= 0) { return; } @@ -1197,44 +1215,61 @@ void XtcReaderActivity::renderStatusBarOverlay(const StatusBarOverlayPosition po &orientedMarginLeft); int clearY; - int paddingBottom = 0; if (position == StatusBarOverlayPosition::Bottom) { clearY = renderer.getScreenHeight() - orientedMarginBottom - statusBarHeight - 4; if (clearY < 0) { clearY = 0; } } else { - clearY = orientedMarginTop; - paddingBottom = renderer.getScreenHeight() - statusBarHeight - orientedMarginBottom - orientedMarginTop - 4; + clearY = std::min(orientedMarginTop, UITheme::getTopStatusBarY(renderer)); } const int clearHeight = position == StatusBarOverlayPosition::Bottom ? renderer.getScreenHeight() - orientedMarginBottom - clearY - : statusBarHeight + 4; + : statusBarHeight + UITheme::getInstance().getMetrics().topPadding + + std::max(0, UITheme::getTopStatusBarInset(renderer)) + 4; if (clearHeight > 0) { renderer.fillRect(0, clearY, renderer.getScreenWidth(), clearHeight, false); } // XTC pages already contain a status strip in their bitmap. Clear that same // overlay area before returning so hiding it does not leave stale pixels. - if (!statusBarVisible) { + if (!statusBarVisible || !drawContent) { return; } const int pageCount = static_cast(xtc->getPageCount()); const int displayPage = static_cast(pageToRender) + 1; const float progress = pageCount > 0 ? (static_cast(displayPage) * 100.0f) / pageCount : 0.0f; - const auto pageInfo = getStatusBarInfo(pageToRender); - char timeLeftLabel[24] = {}; - const char* timeLeft = - formatTimeLeftLabel(timeLeftLabel, sizeof(timeLeftLabel), pageToRender) ? timeLeftLabel : nullptr; - GUI.drawStatusBar(renderer, progress, pageInfo.currentPage, pageInfo.pageCount, pageInfo.title.c_str(), paddingBottom, - 0, false, timeLeft); + const auto config = SETTINGS.readerStatusBar(configuredBar); + const auto pageInfo = getStatusBarInfo(pageToRender, config.contains(ReaderStatusBarItem::TitleChapter)); + char bookTime[24] = {}; + char chapterTime[24] = {}; + const std::string bookTitle = config.contains(ReaderStatusBarItem::TitleBook) ? xtc->getTitle() : ""; + ReaderStatusBarContent content; + content.bookProgress = progress; + content.chapterPage = pageInfo.currentPage; + content.chapterPageCount = pageInfo.pageCount; + content.bookTitle = bookTitle.c_str(); + content.chapterTitle = pageInfo.title.c_str(); + content.timeLeftBook = config.contains(ReaderStatusBarItem::TimeLeftBook) && + formatTimeLeftLabel(bookTime, sizeof(bookTime), pageToRender, true) + ? bookTime + : nullptr; + content.timeLeftChapter = config.contains(ReaderStatusBarItem::TimeLeftChapter) && + formatTimeLeftLabel(chapterTime, sizeof(chapterTime), pageToRender, false) + ? chapterTime + : nullptr; + GUI.drawReaderStatusBar(renderer, displayedBar, content, legacyTop ? &config : nullptr); } void XtcReaderActivity::renderPage(const uint32_t pageToRender) { const uint16_t pageWidth = xtc->getPageWidth(); const uint16_t pageHeight = xtc->getPageHeight(); const uint8_t bitDepth = xtc->getBitDepth(); + const auto renderOverlays = [this, pageToRender](const bool drawContent) { + renderStatusBarOverlay(StatusBarOverlayPosition::Top, pageToRender, drawContent); + renderStatusBarOverlay(StatusBarOverlayPosition::Bottom, pageToRender, drawContent); + }; if (bitDepth == 2) { auto showStreamError = [&]() { @@ -1244,17 +1279,6 @@ void XtcReaderActivity::renderPage(const uint32_t pageToRender) { renderer.drawCenteredText(UI_12_FONT_ID, 300, message, true, EpdFontFamily::BOLD); renderer.displayBuffer(); }; - const auto clearHiddenStatusBar = [this, pageToRender] { - if (statusBarVisible) { - return; - } - if (SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP) { - renderStatusBarOverlay(StatusBarOverlayPosition::Top, pageToRender); - } else { - renderStatusBarOverlay(StatusBarOverlayPosition::Bottom, pageToRender); - } - }; - // XTCH stores two 48 KB planes. Stream each rendering pass through a 1 KB // scratch chunk so fragmented C3 heaps never need one contiguous 96 KB block. renderer.clearScreen(); @@ -1262,7 +1286,7 @@ void XtcReaderActivity::renderPage(const uint32_t pageToRender) { showStreamError(); return; } - clearHiddenStatusBar(); + renderOverlays(true); if (pagesUntilFullRefresh <= 1) { renderer.displayBuffer(pagesUntilFullRefresh < 0 ? manualScreenRefreshMode() : HalDisplay::HALF_REFRESH); @@ -1278,7 +1302,7 @@ void XtcReaderActivity::renderPage(const uint32_t pageToRender) { showStreamError(); return; } - clearHiddenStatusBar(); + renderOverlays(false); renderer.copyGrayscaleLsbBuffers(); renderer.clearScreen(0x00); @@ -1286,7 +1310,7 @@ void XtcReaderActivity::renderPage(const uint32_t pageToRender) { showStreamError(); return; } - clearHiddenStatusBar(); + renderOverlays(false); renderer.copyGrayscaleMsbBuffers(); renderer.displayGrayBuffer(); @@ -1295,7 +1319,7 @@ void XtcReaderActivity::renderPage(const uint32_t pageToRender) { showStreamError(); return; } - clearHiddenStatusBar(); + renderOverlays(true); renderer.cleanupGrayscaleWithFrameBuffer(); return; } @@ -1354,11 +1378,7 @@ void XtcReaderActivity::renderPage(const uint32_t pageToRender) { free(pageBuffer); - if (SETTINGS.xtcStatusBarMode == CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_TOP) { - renderStatusBarOverlay(StatusBarOverlayPosition::Top, pageToRender); - } else { - renderStatusBarOverlay(StatusBarOverlayPosition::Bottom, pageToRender); - } + renderOverlays(true); // Display with appropriate refresh ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index a744f47fccf..e3034d3eeb7 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -59,8 +59,8 @@ class XtcReaderActivity final : public Activity { }; void renderPage(uint32_t pageToRender); - void renderStatusBarOverlay(StatusBarOverlayPosition position, uint32_t pageToRender) const; - StatusBarInfo getStatusBarInfo(uint32_t pageToRender) const; + void renderStatusBarOverlay(StatusBarOverlayPosition position, uint32_t pageToRender, bool drawContent) const; + StatusBarInfo getStatusBarInfo(uint32_t pageToRender, bool includeTitle = false) const; bool saveProgress(uint32_t page); bool queueProgressSave(uint32_t pageToRender); bool flushQueuedProgress(); @@ -71,7 +71,7 @@ class XtcReaderActivity final : public Activity { bool forwardPageReadElapsed(uint32_t& seconds, const char* source) const; void recordCurrentPageReadingTime(const char* source = "unknown"); void recordForwardPageTurn(uint32_t seconds, bool recordPace); - bool formatTimeLeftLabel(char* buf, size_t len, uint32_t pageToRender) const; + bool formatTimeLeftLabel(char* buf, size_t len, uint32_t pageToRender, bool bookEstimate) const; void commitReadingStats(); void resetCurrentBookStatsAfterDelete(); void setBookCompleted(bool isCompleted); diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 1b059097330..b7a125f10ac 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -1,13 +1,11 @@ #include "StatusBarSettingsActivity.h" #include +#include #include #include -#include -#include #include -#include #include #include "CrossPointSettings.h" @@ -19,248 +17,168 @@ namespace fui = freeink::ui; namespace { -enum MenuItem { - ITEM_CHAPTER_PAGE_COUNT = 0, - ITEM_STABLE_PAGE_NUMBERS, - ITEM_BOOK_PROGRESS_PERCENTAGE, - ITEM_BOOK_PERCENTAGE_FORMAT, - ITEM_PROGRESS_BAR, - ITEM_PROGRESS_BAR_THICKNESS, - ITEM_TITLE, - ITEM_TIME_LEFT, - ITEM_BATTERY, - ITEM_XTC_STATUS_BAR, - ITEM_COUNT +enum BarItem { + SLOT_LEFT_1, + SLOT_LEFT_2, + SLOT_LEFT_3, + SLOT_CENTER, + SLOT_RIGHT_1, + SLOT_RIGHT_2, + SLOT_RIGHT_3, + PERCENTAGE_FORMAT, + PROGRESS_BAR, + PROGRESS_BAR_THICKNESS, }; -const StrId menuNames[ITEM_COUNT] = { - StrId::STR_CHAPTER_PAGE_COUNT, - StrId::STR_STABLE_PAGE_NUMBERS, - StrId::STR_BOOK_PROGRESS_PERCENTAGE, - StrId::STR_PERCENTAGE_FORMAT, - StrId::STR_PROGRESS_BAR, - StrId::STR_PROGRESS_BAR_THICKNESS, - StrId::STR_TITLE, - StrId::STR_TIME_LEFT, - StrId::STR_BATTERY, - StrId::STR_XTC_STATUS_BAR, +constexpr ReaderStatusBarItem pickerItems[] = { + ReaderStatusBarItem::Clock, + ReaderStatusBarItem::Battery, + ReaderStatusBarItem::TimeLeftBook, + ReaderStatusBarItem::TimeLeftChapter, + ReaderStatusBarItem::ChapterPageCount, + ReaderStatusBarItem::StablePageNumber, + ReaderStatusBarItem::BookProgressPercentage, + ReaderStatusBarItem::TitleBook, + ReaderStatusBarItem::TitleChapter, + ReaderStatusBarItem::Empty, }; -const StrId percentageFormatNames[CrossPointSettings::BOOK_PERCENTAGE_FORMAT_COUNT] = { - StrId::STR_PERCENTAGE_FORMAT_WHOLE, - StrId::STR_PERCENTAGE_FORMAT_ONE_DECIMAL, - StrId::STR_PERCENTAGE_FORMAT_TWO_DECIMALS, -}; - -constexpr int PROGRESS_BAR_ITEMS = 3; -const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOOK, StrId::STR_CHAPTER}; -const uint8_t progressBarRawValues[PROGRESS_BAR_ITEMS] = { - CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS, - CrossPointSettings::STATUS_BAR_PROGRESS_BAR::BOOK_PROGRESS, - CrossPointSettings::STATUS_BAR_PROGRESS_BAR::CHAPTER_PROGRESS, -}; - -constexpr int PROGRESS_BAR_THICKNESS_ITEMS = 3; -const StrId progressBarThicknessNames[PROGRESS_BAR_THICKNESS_ITEMS] = { - StrId::STR_PROGRESS_BAR_THIN, StrId::STR_PROGRESS_BAR_MEDIUM, StrId::STR_PROGRESS_BAR_THICK}; - -constexpr int TITLE_ITEMS = 3; -const StrId titleNames[TITLE_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOOK, StrId::STR_CHAPTER}; -const uint8_t titleRawValues[TITLE_ITEMS] = { - CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE, - CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE, - CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE, -}; - -constexpr int TIME_LEFT_ITEMS = 3; -const StrId timeLeftNames[TIME_LEFT_ITEMS] = {StrId::STR_HIDE, StrId::STR_CHAPTER, StrId::STR_BOOK}; - -constexpr int XTC_STATUS_BAR_ITEMS = 3; -const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}; - -int optionCountForItem(const int item) { +std::string itemLabel(const ReaderStatusBarItem item) { switch (item) { - case ITEM_BOOK_PERCENTAGE_FORMAT: - return CrossPointSettings::BOOK_PERCENTAGE_FORMAT_COUNT; - case ITEM_PROGRESS_BAR: - return PROGRESS_BAR_ITEMS; - case ITEM_PROGRESS_BAR_THICKNESS: - return PROGRESS_BAR_THICKNESS_ITEMS; - case ITEM_TITLE: - return TITLE_ITEMS; - case ITEM_TIME_LEFT: - return TIME_LEFT_ITEMS; - case ITEM_XTC_STATUS_BAR: - return XTC_STATUS_BAR_ITEMS; + case ReaderStatusBarItem::Clock: + return tr(STR_STATUS_BAR_CLOCK); + case ReaderStatusBarItem::Battery: + return tr(STR_BATTERY); + case ReaderStatusBarItem::TimeLeftBook: + return std::string(tr(STR_TIME_LEFT)) + " (" + tr(STR_BOOK) + ")"; + case ReaderStatusBarItem::TimeLeftChapter: + return std::string(tr(STR_TIME_LEFT)) + " (" + tr(STR_CHAPTER) + ")"; + case ReaderStatusBarItem::ChapterPageCount: + return tr(STR_CHAPTER_PAGE_COUNT); + case ReaderStatusBarItem::StablePageNumber: + return tr(STR_STABLE_PAGE_NUMBERS); + case ReaderStatusBarItem::BookProgressPercentage: + return tr(STR_BOOK_PROGRESS_PERCENTAGE); + case ReaderStatusBarItem::TitleBook: + return std::string(tr(STR_TITLE)) + " (" + tr(STR_BOOK) + ")"; + case ReaderStatusBarItem::TitleChapter: + return std::string(tr(STR_TITLE)) + " (" + tr(STR_CHAPTER) + ")"; default: - return 0; + return tr(STR_STATUS_BAR_EMPTY); } } -const char* optionNameForItem(const int item, const int optionIndex) { - switch (item) { - case ITEM_BOOK_PERCENTAGE_FORMAT: - return I18N.get(percentageFormatNames[optionIndex]); - case ITEM_PROGRESS_BAR: - return I18N.get(progressBarNames[optionIndex]); - case ITEM_PROGRESS_BAR_THICKNESS: - return I18N.get(progressBarThicknessNames[optionIndex]); - case ITEM_TITLE: - return I18N.get(titleNames[optionIndex]); - case ITEM_TIME_LEFT: - return I18N.get(timeLeftNames[optionIndex]); - case ITEM_XTC_STATUS_BAR: - return I18N.get(xtcStatusBarNames[optionIndex]); - default: - return tr(STR_NONE_OPT); - } -} - -uint8_t optionRawValueForItem(const int item, const int optionIndex) { - switch (item) { - case ITEM_PROGRESS_BAR: - return progressBarRawValues[optionIndex]; - case ITEM_TITLE: - return titleRawValues[optionIndex]; +const char* xtcModeLabel(const uint8_t mode) { + switch (mode) { + case CrossPointSettings::XTC_STATUS_BAR_BOTTOM: + return tr(STR_BOTTOM); + case CrossPointSettings::XTC_STATUS_BAR_TOP: + return tr(STR_TOP); + case CrossPointSettings::XTC_STATUS_BAR_BOTH: + return tr(STR_STATUS_BAR_BOTH); default: - return static_cast(optionIndex); + return tr(STR_HIDE); } } -uint8_t currentOptionIndexForItem(const int item) { - uint8_t rawValue = 0; - switch (item) { - case ITEM_BOOK_PERCENTAGE_FORMAT: - rawValue = SETTINGS.statusBarBookPercentageFormat; - break; - case ITEM_PROGRESS_BAR: - rawValue = SETTINGS.statusBarProgressBar; - break; - case ITEM_PROGRESS_BAR_THICKNESS: - rawValue = SETTINGS.statusBarProgressBarThickness; - break; - case ITEM_TITLE: - rawValue = SETTINGS.statusBarTitle; - break; - case ITEM_TIME_LEFT: - rawValue = SETTINGS.statusBarTimeLeft; - break; - case ITEM_XTC_STATUS_BAR: - rawValue = SETTINGS.xtcStatusBarMode; - break; +const char* progressModeLabel(const uint8_t mode) { + switch (mode) { + case CrossPointSettings::BOOK_PROGRESS: + return tr(STR_BOOK); + case CrossPointSettings::CHAPTER_PROGRESS: + return tr(STR_CHAPTER); default: - return 0; - } - - const int optionCount = optionCountForItem(item); - for (int i = 0; i < optionCount; i++) { - if (optionRawValueForItem(item, i) == rawValue) return static_cast(i); + return tr(STR_HIDE); } - return 0; } -void setOptionIndexForItem(const int item, const uint8_t optionIndex) { - const uint8_t rawValue = optionRawValueForItem(item, optionIndex); - switch (item) { - case ITEM_BOOK_PERCENTAGE_FORMAT: - SETTINGS.statusBarBookPercentageFormat = rawValue; - break; - case ITEM_PROGRESS_BAR: - SETTINGS.statusBarProgressBar = rawValue; - break; - case ITEM_PROGRESS_BAR_THICKNESS: - SETTINGS.statusBarProgressBarThickness = rawValue; - break; - case ITEM_TITLE: - SETTINGS.statusBarTitle = rawValue; - break; - case ITEM_TIME_LEFT: - SETTINGS.statusBarTimeLeft = rawValue; - break; - case ITEM_XTC_STATUS_BAR: - SETTINGS.xtcStatusBarMode = rawValue; - break; +const char* thicknessLabel(const uint8_t thickness) { + switch (thickness) { + case CrossPointSettings::PROGRESS_BAR_THIN: + return tr(STR_PROGRESS_BAR_THIN); + case CrossPointSettings::PROGRESS_BAR_THICK: + return tr(STR_PROGRESS_BAR_THICK); default: - break; - } -} - -std::string valueTextForItem(const int item) { - switch (item) { - case ITEM_CHAPTER_PAGE_COUNT: - return SETTINGS.statusBarChapterPageCount ? tr(STR_SHOW) : tr(STR_HIDE); - case ITEM_STABLE_PAGE_NUMBERS: - return SETTINGS.stablePageNumbers ? tr(STR_SHOW) : tr(STR_HIDE); - case ITEM_BOOK_PROGRESS_PERCENTAGE: - return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE); - case ITEM_BATTERY: - return SETTINGS.statusBarBattery ? tr(STR_SHOW) : tr(STR_HIDE); - default: { - const int optionCount = optionCountForItem(item); - const uint8_t optionIndex = currentOptionIndexForItem(item); - if (optionCount == 0 || optionIndex >= optionCount) return tr(STR_HIDE); - return optionNameForItem(item, optionIndex); - } + return tr(STR_PROGRESS_BAR_MEDIUM); } } +const StrId percentageFormatNames[] = {StrId::STR_PERCENTAGE_FORMAT_WHOLE, StrId::STR_PERCENTAGE_FORMAT_ONE_DECIMAL, + StrId::STR_PERCENTAGE_FORMAT_TWO_DECIMALS}; } // namespace void StatusBarSettingsActivity::onEnter() { Activity::onEnter(); - + view = View::Root; selectedIndex = 0; - visibleItemCount = stablePageNumbersAvailable ? ITEM_COUNT : ITEM_COUNT - 1; - uiReady = false; - visibleRows = 1; topIndex = 0; + visibleRows = 1; + listNav.reset(); + uiReady = false; + refreshItemCount(); applySharedUiTheme(app, uiTarget); app.on(ACTION_ROW, &StatusBarSettingsActivity::onRowEvent, this); app.setScreen(&StatusBarSettingsActivity::settingsScreen, this); + requestUpdate(); +} - if (SETTINGS.statusBarBookPercentageFormat >= CrossPointSettings::BOOK_PERCENTAGE_FORMAT_COUNT) { - SETTINGS.statusBarBookPercentageFormat = CrossPointSettings::BOOK_PERCENTAGE_WHOLE; - } - - // Clamp statusBarProgressBar and statusBarTitle in case of corrupt/migrated data - if (SETTINGS.statusBarProgressBar >= PROGRESS_BAR_ITEMS) { - SETTINGS.statusBarProgressBar = CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; - } +void StatusBarSettingsActivity::onExit() { Activity::onExit(); } - if (SETTINGS.statusBarProgressBarThickness >= PROGRESS_BAR_THICKNESS_ITEMS) { - SETTINGS.statusBarProgressBarThickness = CrossPointSettings::STATUS_BAR_PROGRESS_BAR_THICKNESS::PROGRESS_BAR_NORMAL; - } +bool StatusBarSettingsActivity::handleHomeGesture() { + goBack(); + return true; +} - if (SETTINGS.statusBarTitle >= TITLE_ITEMS) { - SETTINGS.statusBarTitle = CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE; +void StatusBarSettingsActivity::goBack() { + if (view == View::Root) { + finishAfterBackPress(); + } else { + view = View::Root; + selectedIndex = 0; + topIndex = 0; + listNav.reset(); + refreshItemCount(); + requestUpdate(); } +} - if (SETTINGS.statusBarTimeLeft >= TIME_LEFT_ITEMS) { - SETTINGS.statusBarTimeLeft = CrossPointSettings::STATUS_BAR_TIME_LEFT::TIME_LEFT_HIDE; - } +ReaderStatusBarPosition StatusBarSettingsActivity::selectedPosition() const { + return view == View::Top ? ReaderStatusBarPosition::Top : ReaderStatusBarPosition::Bottom; +} - if (SETTINGS.xtcStatusBarMode >= XTC_STATUS_BAR_ITEMS) { - SETTINGS.xtcStatusBarMode = CrossPointSettings::XTC_STATUS_BAR_MODE::XTC_STATUS_BAR_HIDE; - } +void StatusBarSettingsActivity::refreshItemCount() { + visibleItemCount = view == View::Root ? 3 : PROGRESS_BAR_THICKNESS + 1; + selectedIndex = std::clamp(selectedIndex, 0, visibleItemCount - 1); +} - requestUpdate(); +int StatusBarSettingsActivity::previewHeight() const { + if (view == View::Root) return 0; + const auto& metrics = UITheme::getInstance().getMetrics(); + return renderer.getLineHeight(UI_10_FONT_ID) + 18 + UITheme::getReaderStatusBarHeight(selectedPosition()) + + metrics.verticalSpacing; } -void StatusBarSettingsActivity::onExit() { Activity::onExit(); } +int StatusBarSettingsActivity::topPreviewOriginY() const { + int marginTop, marginRight, marginBottom, marginLeft; + renderer.getOrientedViewableTRBL(&marginTop, &marginRight, &marginBottom, &marginLeft); + (void)marginRight; + (void)marginBottom; + (void)marginLeft; + return marginTop + UITheme::getInstance().getMetrics().topPadding + UITheme::getTopStatusBarInset(renderer); +} -bool StatusBarSettingsActivity::handleHomeGesture() { - finish(); - return true; +Rect StatusBarSettingsActivity::settingsHeaderRect() const { + Rect header = TouchHeaderBackButton::headerRect(renderer, mappedInput); + if (view == View::Top) header.y = topPreviewOriginY() + previewHeight(); + return header; } void StatusBarSettingsActivity::loop() { if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; - - if (TouchHeaderBackButton::wasTapped(mappedInput, renderer)) { - finishAfterBackPress(); + if (TouchHeaderBackButton::wasTapped(mappedInput, settingsHeaderRect())) { + goBack(); return; } - if (uiReady) { const fui::InputSnapshot snap = touchSnapshotFrom(mappedInput); if (snap.touchPressed || snap.touchReleased) { @@ -269,7 +187,6 @@ void StatusBarSettingsActivity::loop() { if (event) return; } } - if (mappedInput.hasTouch()) { const auto swipe = mappedInput.wasSwipe(); if (swipe == MappedInputManager::SwipeDir::Up || swipe == MappedInputManager::SwipeDir::Down) { @@ -282,37 +199,30 @@ void StatusBarSettingsActivity::loop() { return; } } - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - finishAfterBackPress(); + goBack(); return; } - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { handleSelection(); requestUpdate(); return; } - - // Handle navigation buttonNavigator.onNextRelease([this] { selectedIndex = ButtonNavigator::nextIndex(selectedIndex, visibleItemCount); topIndex = followListSelection(selectedIndex, topIndex, visibleRows, visibleItemCount); requestUpdate(); }); - buttonNavigator.onPreviousRelease([this] { selectedIndex = ButtonNavigator::previousIndex(selectedIndex, visibleItemCount); topIndex = followListSelection(selectedIndex, topIndex, visibleRows, visibleItemCount); requestUpdate(); }); - buttonNavigator.onNextContinuous([this] { selectedIndex = ButtonNavigator::nextIndex(selectedIndex, visibleItemCount); topIndex = followListSelection(selectedIndex, topIndex, visibleRows, visibleItemCount); requestUpdate(); }); - buttonNavigator.onPreviousContinuous([this] { selectedIndex = ButtonNavigator::previousIndex(selectedIndex, visibleItemCount); topIndex = followListSelection(selectedIndex, topIndex, visibleRows, visibleItemCount); @@ -320,57 +230,83 @@ void StatusBarSettingsActivity::loop() { }); } -int StatusBarSettingsActivity::itemForVisibleIndex(const int visibleIndex) const { - return !stablePageNumbersAvailable && visibleIndex >= ITEM_STABLE_PAGE_NUMBERS ? visibleIndex + 1 : visibleIndex; -} - -bool StatusBarSettingsActivity::selectedItemUsesOptionMenu() const { - return optionCountForItem(itemForVisibleIndex(selectedIndex)) > 2; -} - void StatusBarSettingsActivity::handleSelection() { - const int item = itemForVisibleIndex(selectedIndex); - if (selectedItemUsesOptionMenu()) { - openOptionPicker(); + if (view == View::Root && selectedIndex < 2) { + view = selectedIndex == 0 ? View::Top : View::Bottom; + selectedIndex = 0; + topIndex = 0; + listNav.reset(); + refreshItemCount(); + requestUpdate(); return; } - - switch (item) { - case ITEM_CHAPTER_PAGE_COUNT: - SETTINGS.statusBarChapterPageCount = (SETTINGS.statusBarChapterPageCount + 1) % 2; - break; - case ITEM_STABLE_PAGE_NUMBERS: - SETTINGS.stablePageNumbers = (SETTINGS.stablePageNumbers + 1) % 2; - break; - case ITEM_BOOK_PROGRESS_PERCENTAGE: - SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2; - break; - case ITEM_BATTERY: - SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2; - break; - default: - return; - } - SETTINGS.saveToFile(); + openOptionPicker(); } void StatusBarSettingsActivity::openOptionPicker() { - const int item = itemForVisibleIndex(selectedIndex); - const int optionCount = optionCountForItem(item); - if (optionCount <= 0) return; + if (view == View::Root) { + const std::vector options = {tr(STR_HIDE), tr(STR_BOTTOM), tr(STR_TOP), tr(STR_STATUS_BAR_BOTH)}; + optionPopup.show(StrId::STR_XTC_STATUS_BAR, options, SETTINGS.xtcStatusBarMode, [this](const int selected) { + if (SETTINGS.xtcStatusBarMode != selected) SETTINGS.legacyXtcTopUsesBottom = 0; + SETTINGS.xtcStatusBarMode = static_cast(selected); + SETTINGS.saveToFile(); + requestUpdate(); + }); + requestUpdate(); + return; + } + const auto position = selectedPosition(); + const int item = selectedIndex; + const auto config = SETTINGS.readerStatusBar(position); std::vector options; - options.reserve(optionCount); - for (int i = 0; i < optionCount; i++) { - options.push_back(optionNameForItem(item, i)); + std::vector rawValues; + StrId titleId = view == View::Top ? StrId::STR_TOP_STATUS_BAR : StrId::STR_BOTTOM_STATUS_BAR; + int currentIndex = 0; + if (item <= SLOT_RIGHT_3) { + for (const auto choice : pickerItems) { + if (choice == ReaderStatusBarItem::Clock && !halClock.isAvailable()) continue; + if (config.slots[item] == choice) currentIndex = static_cast(options.size()); + options.push_back(itemLabel(choice)); + rawValues.push_back(static_cast(choice)); + } + } else if (item == PERCENTAGE_FORMAT) { + titleId = StrId::STR_PERCENTAGE_FORMAT; + for (uint8_t i = 0; i < CrossPointSettings::BOOK_PERCENTAGE_FORMAT_COUNT; ++i) { + options.emplace_back(I18N.get(percentageFormatNames[i])); + rawValues.push_back(i); + } + currentIndex = config.percentageFormat; + } else if (item == PROGRESS_BAR) { + titleId = StrId::STR_PROGRESS_BAR; + options = {tr(STR_HIDE), tr(STR_BOOK), tr(STR_CHAPTER)}; + rawValues = {CrossPointSettings::HIDE_PROGRESS, CrossPointSettings::BOOK_PROGRESS, + CrossPointSettings::CHAPTER_PROGRESS}; + for (unsigned i = 0; i < rawValues.size(); ++i) { + if (rawValues[i] == config.progressBar) currentIndex = static_cast(i); + } + } else { + titleId = StrId::STR_PROGRESS_BAR_THICKNESS; + options = {tr(STR_PROGRESS_BAR_THIN), tr(STR_PROGRESS_BAR_MEDIUM), tr(STR_PROGRESS_BAR_THICK)}; + rawValues = {0, 1, 2}; + currentIndex = config.progressBarThickness; } - - uint8_t currentIndex = currentOptionIndexForItem(item); - if (currentIndex >= optionCount) currentIndex = 0; - - optionPopup.show(menuNames[item], options, currentIndex, [this, item](int selectedIndex) { - setOptionIndexForItem(item, static_cast(selectedIndex)); + optionPopup.show(titleId, options, currentIndex, [this, position, item, rawValues](const int selected) { + if (selected < 0 || static_cast(selected) >= rawValues.size()) return; + auto updated = SETTINGS.readerStatusBar(position); + const uint8_t value = rawValues[selected]; + if (item <= SLOT_RIGHT_3) { + updated.slots[item] = static_cast(value); + } else if (item == PERCENTAGE_FORMAT) { + updated.percentageFormat = value; + } else if (item == PROGRESS_BAR) { + updated.progressBar = value; + } else { + updated.progressBarThickness = value; + } + SETTINGS.setReaderStatusBar(position, updated); SETTINGS.saveToFile(); + refreshItemCount(); requestUpdate(); }); requestUpdate(); @@ -393,36 +329,60 @@ void StatusBarSettingsActivity::buildSettingsScreen(UiApp::ScreenType& screen) { const int pageWidth = renderer.getScreenWidth(); const int pageHeight = renderer.getScreenHeight(); const auto orientation = renderer.getOrientation(); - const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise; - const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise; - const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.buttonHintsHeight : 0; - const int contentX = isLandscapeCw ? hintGutterWidth : 0; + const bool landscape = orientation == GfxRenderer::Orientation::LandscapeClockwise || + orientation == GfxRenderer::Orientation::LandscapeCounterClockwise; + const int hintGutterWidth = landscape ? metrics.buttonHintsHeight : 0; + const int contentX = orientation == GfxRenderer::Orientation::LandscapeClockwise ? hintGutterWidth : 0; const int contentWidth = pageWidth - hintGutterWidth; - const int contentTop = - metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + metrics.verticalSpacing; - const int previewLabelLineHeight = renderer.getLineHeight(UI_10_FONT_ID); - constexpr int previewLabelGap = 18; - const int previewSectionHeight = previewLabelLineHeight + previewLabelGap + UITheme::getStatusBarHeight(); + const Rect header = settingsHeaderRect(); + const int contentTop = header.y + header.height + metrics.verticalSpacing; + const int bottomPreviewHeight = view == View::Bottom ? previewHeight() : 0; const int contentHeight = - pageHeight - contentTop - metrics.buttonHintsHeight - previewSectionHeight - metrics.verticalSpacing * 2; - + pageHeight - contentTop - metrics.buttonHintsHeight - bottomPreviewHeight - metrics.verticalSpacing * 2; screen.setContentMargin( fui::Insets{static_cast(contentTop), static_cast(pageWidth - (contentX + contentWidth)), static_cast(pageHeight - (contentTop + contentHeight)), static_cast(contentX)}); + const auto config = view == View::Root ? ReaderStatusBarConfig{} : SETTINGS.readerStatusBar(selectedPosition()); + std::vector labels(visibleItemCount); std::vector values(visibleItemCount); std::vector items; items.reserve(visibleItemCount); for (int i = 0; i < visibleItemCount; ++i) { - const int itemIndex = itemForVisibleIndex(i); - values[i] = valueTextForItem(itemIndex); - fui::ListItem item; - item.label = I18N.get(menuNames[itemIndex]); - item.value = values[i].c_str(); - item.actionValue = static_cast(i); - items.push_back(item); + fui::ListItem row; + row.actionValue = static_cast(i); + if (view == View::Root) { + row.label = i == 0 ? tr(STR_TOP_STATUS_BAR) : i == 1 ? tr(STR_BOTTOM_STATUS_BAR) : tr(STR_XTC_STATUS_BAR); + row.value = i == 2 ? xtcModeLabel(SETTINGS.xtcStatusBarMode) : ">"; + } else { + const int item = i; + if (item <= SLOT_RIGHT_3) { + values[i] = itemLabel(config.slots[item]); + if (item <= SLOT_LEFT_3) { + labels[i] = std::string(tr(STR_STATUS_BAR_LEFT)) + " " + std::to_string(item + 1); + } else if (item == SLOT_CENTER) { + labels[i] = tr(STR_CENTER); + } else { + labels[i] = std::string(tr(STR_STATUS_BAR_RIGHT)) + " " + std::to_string(item - SLOT_RIGHT_1 + 1); + } + row.label = labels[i].c_str(); + row.value = values[i].c_str(); + if (item == SLOT_LEFT_1) row.sectionHeading = tr(STR_STATUS_BAR_LEFT); + if (item == SLOT_CENTER) row.sectionHeading = tr(STR_CENTER); + if (item == SLOT_RIGHT_1) row.sectionHeading = tr(STR_STATUS_BAR_RIGHT); + } else if (item == PERCENTAGE_FORMAT) { + row.label = tr(STR_PERCENTAGE_FORMAT); + row.value = I18N.get(percentageFormatNames[config.percentageFormat]); + } else if (item == PROGRESS_BAR) { + row.label = tr(STR_PROGRESS_BAR); + row.value = progressModeLabel(config.progressBar); + } else { + row.label = tr(STR_PROGRESS_BAR_THICKNESS); + row.value = thicknessLabel(config.progressBarThickness); + } + } + items.push_back(row); } - fui::ListProps props; props.items = items.data(); props.count = static_cast(items.size()); @@ -432,78 +392,163 @@ void StatusBarSettingsActivity::buildSettingsScreen(UiApp::ScreenType& screen) { props.valueInset = 8; props.labelText = screen.theme().bodyText; props.labelText.maxLines = 2; + props.headerText = screen.theme().smallText; + props.headerText.bold = true; const auto rows = configureUiList(props, screen.theme(), screen.body()); - visibleRows = rows > 0 ? rows : 1; + // Section headings and wrapped values can fit fewer rows than this fixed-height estimate. + visibleRows = listNav.trusts(visibleItemCount) ? listNav.pageRowsFor(visibleItemCount) : std::max(rows, 1); topIndex = scrollListBy(topIndex, 0, visibleRows, visibleItemCount); props.topIndex = static_cast(topIndex); - screen.list(props); + if (view == View::Root) { + screen.list(props); + return; + } + + const auto renderScrollingList = [&] { + props.nav = &listNav; + screen.list(props); + visibleRows = listNav.pageRowsFor(visibleItemCount); + topIndex = listNav.top; + }; + + // Keep the separator aligned with FreeInkUI's variable-height rows when the list scrolls or wraps. + const auto resolvedProps = screen.resolveListProps(props); + const auto body = screen.body(); + const int dividerItem = SLOT_RIGHT_3; + const int dividerIndex = [&] { + for (int i = topIndex; i < visibleItemCount; ++i) { + if (i == dividerItem) return i; + } + return -1; + }(); + if (dividerIndex < topIndex) { + renderScrollingList(); + return; + } + + fui::ListProps layoutProps = resolvedProps; + layoutProps.scrollIndicator = false; + int16_t rowWidth = body.width; + if (layoutProps.rowInset > 0) rowWidth = static_cast(rowWidth - layoutProps.rowInset * 2); + const int16_t headerLineHeight = screen.target().lineHeight(layoutProps.headerText.font); + const int16_t headerHeight = + layoutProps.headerRowHeight > 0 ? layoutProps.headerRowHeight : static_cast(headerLineHeight + 4); + int16_t dividerY = body.y; + for (int i = topIndex; i <= dividerIndex; ++i) { + const auto& item = items[static_cast(i)]; + if (item.sectionHeading != nullptr && item.sectionHeading[0] != '\0') { + if (i != topIndex) dividerY = static_cast(dividerY + layoutProps.sectionGap); + dividerY = static_cast(dividerY + headerHeight + layoutProps.rowGap); + } + dividerY = static_cast(dividerY + + fui::measureListRow(screen.target(), nullptr, rowWidth, layoutProps, item).height); + if (i != dividerIndex) dividerY = static_cast(dividerY + layoutProps.rowGap); + } + + int16_t afterDividerHeight = 0; + for (int i = dividerIndex + 1; i < visibleItemCount; ++i) { + const auto& item = items[static_cast(i)]; + afterDividerHeight = static_cast( + afterDividerHeight + fui::measureListRow(screen.target(), nullptr, rowWidth, layoutProps, item).height); + if (i + 1 < visibleItemCount) afterDividerHeight = static_cast(afterDividerHeight + layoutProps.rowGap); + } + const int16_t availableAfterDivider = static_cast(body.bottom() - dividerY); + const int16_t maxDividerMargin = static_cast((availableAfterDivider - afterDividerHeight - 1) / 2); + if (maxDividerMargin < 0) { + // On compact screens, keep every option in one scrollable list instead of + // splitting off a segment that cannot fit its first row. + renderScrollingList(); + screen.target().fill(fui::Rect{body.x, dividerY, body.width, 1}, fui::Paint::solid(fui::Color::Black)); + return; + } + const int16_t dividerMargin = std::min(layoutProps.sectionGap, maxDividerMargin); + + fui::ListProps beforeDivider = props; + beforeDivider.items = items.data() + topIndex; + beforeDivider.count = static_cast(dividerIndex - topIndex + 1); + beforeDivider.topIndex = 0; + beforeDivider.selectedIndex = + selectedIndex >= topIndex && selectedIndex <= dividerIndex ? static_cast(selectedIndex - topIndex) : -1; + beforeDivider.scrollIndicator = false; + screen.list(beforeDivider, static_cast(dividerY - body.y)); + + screen.spacer(dividerMargin); + const auto dividerBody = screen.body(); + screen.target().fill(fui::Rect{dividerBody.x, dividerBody.y, dividerBody.width, 1}, + fui::Paint::solid(fui::Color::Black)); + screen.spacer(1); + screen.spacer(dividerMargin); + + fui::ListProps afterDivider = props; + afterDivider.items = items.data() + dividerIndex + 1; + afterDivider.count = static_cast(visibleItemCount - dividerIndex - 1); + afterDivider.topIndex = 0; + afterDivider.selectedIndex = + selectedIndex > dividerIndex ? static_cast(selectedIndex - dividerIndex - 1) : -1; + afterDivider.scrollIndicator = false; + screen.list(afterDivider); + + // The split sections share one scroll position, so keep one indicator for the full list. + if (resolvedProps.scrollIndicator) { + fui::drawListScrollIndicator(screen.target(), body, static_cast(visibleItemCount), + static_cast(visibleItemCount - topIndex), static_cast(topIndex), + resolvedProps.scrollIndicatorWidth, resolvedProps.scrollIndicatorSide, + resolvedProps.scrollIndicatorInset); + } } void StatusBarSettingsActivity::render(RenderLock&&) { if (optionPopup.processRender(renderer, mappedInput)) return; - renderer.clearScreen(); - const auto& metrics = UITheme::getInstance().getMetrics(); - const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); - + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); const auto orientation = renderer.getOrientation(); - const bool isInverted = orientation == GfxRenderer::Orientation::PortraitInverted; - const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise; - const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise; - const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.buttonHintsHeight : 0; - const int contentX = isLandscapeCw ? hintGutterWidth : 0; - const int contentWidth = pageWidth - hintGutterWidth; - - const int previewLabelLineHeight = renderer.getLineHeight(UI_10_FONT_ID); - constexpr int previewLabelGap = 18; - - const auto labels = mappedInput.mapLabels(mappedInput.withBackArrow(tr(STR_BACK)), - selectedItemUsesOptionMenu() ? tr(STR_SELECT) : tr(STR_TOGGLE), - tr(STR_DIR_UP), tr(STR_DIR_DOWN)); - - int bottomPreviewPadding = metrics.buttonHintsHeight + metrics.verticalSpacing; - - const Rect header = TouchHeaderBackButton::headerRect(renderer, mappedInput); + const bool landscape = orientation == GfxRenderer::Orientation::LandscapeClockwise || + orientation == GfxRenderer::Orientation::LandscapeCounterClockwise; + const int contentX = orientation == GfxRenderer::Orientation::LandscapeClockwise ? metrics.buttonHintsHeight : 0; + const int contentWidth = pageWidth - (landscape ? metrics.buttonHintsHeight : 0); + const char* headerTitle = view == View::Root ? tr(STR_STATUS_BARS) + : view == View::Top ? tr(STR_TOP_STATUS_BAR) + : tr(STR_BOTTOM_STATUS_BAR); + const auto labels = + mappedInput.mapLabels(mappedInput.withBackArrow(tr(STR_BACK)), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + const Rect header = settingsHeaderRect(); + const bool showHeaderStatus = view == View::Root; if (mappedInput.hasTouchHardware()) { - TouchHeaderBackButton::draw(renderer, uiTarget, header, tr(STR_CUSTOMISE_STATUS_BAR), readerContext); + TouchHeaderBackButton::draw(renderer, uiTarget, header, headerTitle, readerContext, 0, nullptr, + TouchHeaderBackButton::TITLE_VERTICAL_OFFSET, showHeaderStatus); } else { - GUI.drawHeader( - renderer, Rect{contentX, metrics.topPadding, contentWidth, TouchHeaderBackButton::height(metrics, mappedInput)}, - tr(STR_CUSTOMISE_STATUS_BAR), nullptr, readerContext); + GUI.drawHeader(renderer, Rect{contentX, header.y, contentWidth, header.height}, headerTitle, nullptr, readerContext, + showHeaderStatus); } - uiReady = false; app.render(); uiReady = true; - // Draw button hints GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4, true); - - std::string title; - if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::BOOK_TITLE) { - title = tr(STR_EXAMPLE_BOOK); - } else if (SETTINGS.statusBarTitle == CrossPointSettings::STATUS_BAR_TITLE::CHAPTER_TITLE) { - title = tr(STR_EXAMPLE_CHAPTER); - } - - if (isLandscapeCw || isLandscapeCcw || isInverted) { - bottomPreviewPadding = 0; + if (view != View::Root) { + const auto position = selectedPosition(); + const int barHeight = UITheme::getReaderStatusBarHeight(position); + const int previewOriginY = view == View::Top + ? topPreviewOriginY() + : pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - barHeight; + const int labelY = view == View::Top ? previewOriginY + barHeight + 9 + : previewOriginY - renderer.getLineHeight(UI_10_FONT_ID) - 18; + renderer.drawText(UI_10_FONT_ID, contentX + metrics.contentSidePadding, labelY, tr(STR_PREVIEW)); + ReaderStatusBarContent content; + content.bookProgress = 75.12f; + content.chapterProgress = 35.0f; + content.chapterPage = 8; + content.chapterPageCount = 32; + content.stablePage = 120; + content.stablePageCount = 540; + content.bookTitle = tr(STR_EXAMPLE_BOOK); + content.chapterTitle = tr(STR_EXAMPLE_CHAPTER); + content.timeLeftBook = "3h 40m"; + content.timeLeftChapter = "1h 20m"; + content.previewClock = "12:34"; + content.previewOriginY = previewOriginY; + GUI.drawReaderStatusBar(renderer, position, content); } - - const char* timeLeftPreview = nullptr; - if (SETTINGS.statusBarTimeLeft == CrossPointSettings::STATUS_BAR_TIME_LEFT::TIME_LEFT_CHAPTER) { - timeLeftPreview = "1h 20m"; - } else if (SETTINGS.statusBarTimeLeft == CrossPointSettings::STATUS_BAR_TIME_LEFT::TIME_LEFT_BOOK) { - timeLeftPreview = "3h 40m"; - } - const int previewX = contentX + metrics.contentSidePadding; - const int bottomPreviewTop = pageHeight - UITheme::getStatusBarHeight() - bottomPreviewPadding; - const int previewLabelY = bottomPreviewTop - previewLabelLineHeight - previewLabelGap; - - renderer.drawText(UI_10_FONT_ID, previewX, previewLabelY, tr(STR_PREVIEW)); - GUI.drawStatusBar(renderer, 75.12f, 8, 32, title.c_str(), bottomPreviewPadding, 0, false, timeLeftPreview, false, - -1.0f, stablePageNumbersAvailable ? 120 : 0, stablePageNumbersAvailable ? 540 : 0); - renderer.displayBuffer(); } diff --git a/src/activities/settings/StatusBarSettingsActivity.h b/src/activities/settings/StatusBarSettingsActivity.h index f8c2c7b0727..d25edbe6a8b 100644 --- a/src/activities/settings/StatusBarSettingsActivity.h +++ b/src/activities/settings/StatusBarSettingsActivity.h @@ -12,6 +12,7 @@ #include "components/UIThemeTokens.h" #include "components/UiAppHelpers.h" #include "util/ButtonNavigator.h" +#include "util/ReaderStatusBarConfig.h" // Reader status bar configuration activity class StatusBarSettingsActivity final : public Activity { @@ -20,9 +21,10 @@ class StatusBarSettingsActivity final : public Activity { bool stablePageNumbersAvailable = false) : Activity("StatusBarSettings", renderer, mappedInput), readerContext(readerContext), - stablePageNumbersAvailable(stablePageNumbersAvailable), uiTarget(makeUiTarget(renderer)), - app(uiTarget, uiTarget.deviceContext()) {} + app(uiTarget, uiTarget.deviceContext()) { + (void)stablePageNumbersAvailable; + } void onEnter() override; void onExit() override; @@ -31,26 +33,32 @@ class StatusBarSettingsActivity final : public Activity { bool handleHomeGesture() override; private: + enum class View : uint8_t { Root, Top, Bottom }; ButtonNavigator buttonNavigator; OptionPopup optionPopup; int selectedIndex = 0; int visibleItemCount = 0; bool readerContext = false; - bool stablePageNumbersAvailable = false; + View view = View::Root; using UiApp = freeink::ui::FreeInkApp<20, 4>; static constexpr freeink::ui::ActionId ACTION_ROW = 1; freeink::ui::GfxRendererTarget uiTarget; // Must precede app: the app holds a reference to it. UiApp app; std::atomic uiReady{false}; + freeink::ui::ListNav listNav; int visibleRows = 1; int topIndex = 0; - int itemForVisibleIndex(int visibleIndex) const; - bool selectedItemUsesOptionMenu() const; void handleSelection(); void openOptionPicker(); + void goBack(); + ReaderStatusBarPosition selectedPosition() const; + void refreshItemCount(); + int previewHeight() const; + int topPreviewOriginY() const; + Rect settingsHeaderRect() const; static void settingsScreen(UiApp::ScreenType& screen, void* user); static void onRowEvent(const freeink::ui::ActionEvent& event, void* user); void buildSettingsScreen(UiApp::ScreenType& screen); diff --git a/src/activities/util/FrontlightPanelActivity.cpp b/src/activities/util/FrontlightPanelActivity.cpp index bbf90802077..e4d55343619 100644 --- a/src/activities/util/FrontlightPanelActivity.cpp +++ b/src/activities/util/FrontlightPanelActivity.cpp @@ -25,6 +25,7 @@ #include "components/icons/readingStatsIcons.h" #include "components/icons/tablerIcons.h" #include "components/icons/touchscreenStateIcons.h" +#include "util/ReaderBookProgress.h" namespace fui = freeink::ui; @@ -370,6 +371,7 @@ int FrontlightPanelActivity::computePanelBottom() const { const auto tokens = uiThemeTokens(uiTarget); const int16_t lh = uiTarget.lineHeight(tokens.bodyText.font); int y = metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput); + if (showsBookProgress()) y += metrics.tabBarHeight; if (context.showReaderDetails) { const int16_t titleLh = uiTarget.lineHeight(tokens.titleText.font); y += tokens.spaceLg * 2; @@ -451,8 +453,9 @@ void FrontlightPanelActivity::buildPanelScreen(UiApp::ScreenType& screen) { drawerHandleRect = DrawerHandle::registerTap(screen.frame(), sheetContent, sheet, ACTION_DISMISS); const int16_t bottomInset = static_cast(renderer.getScreenHeight() - sheetContent.bottom()); screen.setContentMargin( - fui::Insets{static_cast(metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput)), 0, - bottomInset, 0}); + fui::Insets{static_cast(metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + + (showsBookProgress() ? metrics.tabBarHeight : 0)), + 0, bottomInset, 0}); const fui::Rect actionBar = screen.takeBottom(ACTION_BAR_HEIGHT); const int16_t slotWidth = static_cast(actionBar.width / 5); @@ -555,8 +558,8 @@ void FrontlightPanelActivity::drawHeader() { const int titleX = header.x + tokens.headerSidePadding; const int titleRight = homeButton.x - tokens.spaceSm; const int titleWidth = std::max(0, titleRight - titleX); - const std::string visibleTitle = renderer.truncatedText(titleFontId, title, titleWidth, EpdFontFamily::REGULAR); - renderer.drawText(titleFontId, titleX, titleY, visibleTitle.c_str(), true); + const std::string visibleTitle = renderer.truncatedText(titleFontId, title, titleWidth, EpdFontFamily::BOLD); + renderer.drawText(titleFontId, titleX, titleY, visibleTitle.c_str(), true, EpdFontFamily::BOLD); } else { UITheme::drawCenteredText(renderer, header, titleFontId, titleY, title, true); } @@ -570,6 +573,23 @@ void FrontlightPanelActivity::drawHeader() { } } +bool FrontlightPanelActivity::showsBookProgress() const { + return context.activeReaderBook && !context.showReaderDetails && !context.bookDetails.title.empty(); +} + +void FrontlightPanelActivity::drawBookProgress() { + if (!showsBookProgress()) return; + const auto& metrics = UITheme::getInstance().getMetrics(); + const int y = metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput); + const Rect row{0, y, renderer.getScreenWidth(), metrics.tabBarHeight}; + char progress[96]; + formatReaderBookProgress(progress, sizeof(progress), context.bookDetails.chapterPage, + context.bookDetails.chapterPageCount, context.bookDetails.chapterPageCountEstimated, + context.bookDetails.progressPercent); + GUI.drawSubHeader(renderer, row, progress); + renderer.drawLine(row.x, row.y + row.height - 1, row.x + row.width - 1, row.y + row.height - 1, 1, true); +} + void FrontlightPanelActivity::addStepSlider(UiApp::ScreenType& screen, const fui::Rect& row, const uint8_t value, const fui::ActionId sliderAction, const fui::ActionId stepAction) { const auto& theme = screen.theme(); @@ -604,6 +624,7 @@ void FrontlightPanelActivity::render(RenderLock&&) { app.render(); uiReady = true; drawHeader(); + drawBookProgress(); // The dialog is the topmost layer. if (optionPopup.isActive()) optionPopup.render(renderer); diff --git a/src/activities/util/FrontlightPanelActivity.h b/src/activities/util/FrontlightPanelActivity.h index 27a02b2ba39..d8fa04d8529 100644 --- a/src/activities/util/FrontlightPanelActivity.h +++ b/src/activities/util/FrontlightPanelActivity.h @@ -78,6 +78,8 @@ class FrontlightPanelActivity final : public Activity { void openReadingStats(); void openGlobalSettings(); void drawHeader(); + bool showsBookProgress() const; + void drawBookProgress(); public: explicit FrontlightPanelActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/activities/util/FrontlightPanelModel.h b/src/activities/util/FrontlightPanelModel.h index 620c34eb835..23975b2ff32 100644 --- a/src/activities/util/FrontlightPanelModel.h +++ b/src/activities/util/FrontlightPanelModel.h @@ -40,6 +40,9 @@ struct FrontlightPanelBookDetails { std::string title; std::string author; std::string chapter; + uint32_t chapterPage = 0; + uint32_t chapterPageCount = 0; + bool chapterPageCountEstimated = false; int progressPercent = 0; }; diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index b8411cc15f7..e87992375bb 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -20,11 +20,19 @@ namespace fui = freeink::ui; namespace { constexpr fui::ActionId ACTION_KEY = 1; +constexpr int KEYBOARD_MIN_GAP = 6; +constexpr int KEYBOARD_PANEL_PADDING = 4; constexpr int BUTTON_KEY_HEIGHT = 42; -constexpr int BUTTON_KEY_ROW_GAP = 0; -constexpr int BUTTON_KEY_BOTTOM_SHIFT = 10; +constexpr int BUTTON_KEYBOARD_HINT_GAP = 4; constexpr int SIDE_HINT_CLEARANCE = 6; +int keyboardGap(const ThemeMetrics& metrics) { return std::max(metrics.keyboardKeySpacing, KEYBOARD_MIN_GAP); } + +int keyboardKeysHeight(const ThemeMetrics& metrics, const int rows, const bool hasTouch) { + const int keyHeight = hasTouch ? metrics.keyboardKeyHeight : BUTTON_KEY_HEIGHT; + return rows * keyHeight + (rows > 1 ? (rows - 1) * keyboardGap(metrics) : 0) + KEYBOARD_PANEL_PADDING * 2; +} + // --------------------------------------------------------------------------- // URL layers. The SDK builtin layouts have no URL variant (":", "/", ".", the // snippet panel), so these are app-defined tables over the same public @@ -32,14 +40,19 @@ constexpr int SIDE_HINT_CLEARANCE = 6; // regardless of UI language. // --------------------------------------------------------------------------- +constexpr uint8_t URL_KEY_WIDTH = 2; +constexpr uint8_t URL_WIDE_CONTROL_WIDTH = 3; + #define UK(label, output, value) \ - fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, 1, true, nullptr } + fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, URL_KEY_WIDTH, true, nullptr } #define UKA(label, output, value, alt) \ - fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, 1, true, alt } + fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, URL_KEY_WIDTH, true, alt } #define UKW(label, output, value, units) \ - fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, units, true, nullptr } + fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, units *URL_KEY_WIDTH, true, nullptr } #define UKS(label, kind, value, units) \ - fui::KeyboardKey { label, nullptr, kind, fui::StateNormal, value, units, true, nullptr } + fui::KeyboardKey { label, nullptr, kind, fui::StateNormal, value, units *URL_KEY_WIDTH, true, nullptr } +#define UK15(label, kind, value) \ + fui::KeyboardKey { label, nullptr, kind, fui::StateNormal, value, URL_WIDE_CONTROL_WIDTH, true, nullptr } constexpr int16_t URL_PANEL_VALUE = -3; // mirrors KeyboardEntryActivity::URL_PANEL_KEY @@ -54,7 +67,7 @@ const fui::KeyboardKey URL_ROW1[] = {UK("q", "q", 'q'), UK("w", "w", 'w'), UK("e const fui::KeyboardKey URL_ROW2[] = {UK("a", "a", 'a'), UK("s", "s", 's'), UK("d", "d", 'd'), UK("f", "f", 'f'), UK("g", "g", 'g'), UK("h", "h", 'h'), UK("j", "j", 'j'), UK("k", "k", 'k'), UK("l", "l", 'l')}; -const fui::KeyboardKey URL_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT, 2), +const fui::KeyboardKey URL_ROW3[] = {UK15(nullptr, fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT), UK("z", "z", 'z'), UK("x", "x", 'x'), UK("c", "c", 'c'), @@ -62,7 +75,7 @@ const fui::KeyboardKey URL_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui::QWER UK("b", "b", 'b'), UK("n", "n", 'n'), UK("m", "m", 'm'), - UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2)}; + UK15("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE)}; // URLs have no spaces, so the URL bottom row spends the space slot on ":", // "/", "." and the snippet-panel toggle instead (the legacy keyboard did the // same with its "URL" key). @@ -70,7 +83,7 @@ const fui::KeyboardKey URL_BOTTOM[] = {UKS("?123", fui::KeyKind::Mode, fui::QWER UK(":", ":", ':'), UK("/", "/", '/'), UK(".", ".", '.'), - UKW("URL", nullptr, URL_PANEL_VALUE, 2), + UKW("URL", nullptr, URL_PANEL_VALUE, 3), UKS("OK", fui::KeyKind::Ok, fui::QWERTY_KEY_ENTER, 2)}; const fui::KeyboardKey URL_SHIFT_ROW1[] = {UK("Q", "Q", 'Q'), UK("W", "W", 'W'), UK("E", "E", 'E'), UK("R", "R", 'R'), @@ -79,7 +92,7 @@ const fui::KeyboardKey URL_SHIFT_ROW1[] = {UK("Q", "Q", 'Q'), UK("W", "W", 'W'), const fui::KeyboardKey URL_SHIFT_ROW2[] = {UK("A", "A", 'A'), UK("S", "S", 'S'), UK("D", "D", 'D'), UK("F", "F", 'F'), UK("G", "G", 'G'), UK("H", "H", 'H'), UK("J", "J", 'J'), UK("K", "K", 'K'), UK("L", "L", 'L')}; -const fui::KeyboardKey URL_SHIFT_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT, 2), +const fui::KeyboardKey URL_SHIFT_ROW3[] = {UK15(nullptr, fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT), UK("Z", "Z", 'Z'), UK("X", "X", 'X'), UK("C", "C", 'C'), @@ -87,7 +100,7 @@ const fui::KeyboardKey URL_SHIFT_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui UK("B", "B", 'B'), UK("N", "N", 'N'), UK("M", "M", 'M'), - UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2)}; + UK15("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE)}; // Snippet keys: multi-character outputs, stable ids above the localized-key // range so they never collide with layout key ids. @@ -98,14 +111,15 @@ const fui::KeyboardKey URL_SNIP_ROW2[] = {UK("http://", "http://", 2004), UK("19 const fui::KeyboardKey URL_SNIP_ROW3[] = {UK("/opds", "/opds", 2007), UK(":8080", ":8080", 2008), UK(".net", ".net", 2009)}; const fui::KeyboardKey URL_SNIP_BOTTOM[] = {UKS("abc", fui::KeyKind::Mode, fui::QWERTY_KEY_MODE, 2), - UKW("URL", nullptr, URL_PANEL_VALUE, 2), - UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2), + UKW("URL", nullptr, URL_PANEL_VALUE, 3), + UK15("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE), UKS("OK", fui::KeyKind::Ok, fui::QWERTY_KEY_ENTER, 2)}; #undef UK #undef UKA #undef UKW #undef UKS +#undef UK15 const fui::KeyboardRow URL_ROWS[] = { {URL_NUM_ROW, 10, 0}, {URL_ROW1, 10, 0}, {URL_ROW2, 9, 1}, {URL_ROW3, 9, 0}, {URL_BOTTOM, 6, 0}}; @@ -520,22 +534,18 @@ fui::Rect KeyboardEntryActivity::keyboardRect() const { const int pageWidth = renderer.getScreenWidth(); const int pageHeight = renderer.getScreenHeight(); const int rows = currentLayout().rowCount; - const bool buttonOnly = !mappedInput.hasTouchHardware(); - const int rowGap = buttonOnly ? BUTTON_KEY_ROW_GAP : metrics.keyboardKeySpacing; - const int keyHeight = buttonOnly ? BUTTON_KEY_HEIGHT : metrics.keyboardKeyHeight; - const int height = rows * keyHeight + (rows > 1 ? (rows - 1) * rowGap : 0); - int width = pageWidth * metrics.keyboardWidthPercent / 100; - const auto orientation = renderer.getOrientation(); - if (buttonOnly && (orientation == GfxRenderer::Orientation::LandscapeClockwise || - orientation == GfxRenderer::Orientation::LandscapeCounterClockwise)) { - // In landscape, the keyboard cannot fit below the side-button hints and above the footer. - width = std::min(width, pageWidth - 2 * (metrics.sideButtonHintsWidth + SIDE_HINT_CLEARANCE)); + const bool hasTouch = mappedInput.hasTouchHardware(); + const int height = keyboardKeysHeight(metrics, rows, hasTouch); + const int hintGap = hasTouch ? metrics.verticalSpacing - metrics.keyboardVerticalOffset : BUTTON_KEYBOARD_HINT_GAP; + int y = pageHeight - metrics.buttonHintsHeight - height - hintGap; + if (hasTouch) { + const int inputStartY = metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + + metrics.verticalSpacing * 5 + metrics.keyboardVerticalOffset; + const int inputBottom = inputStartY + renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing + 8; + y = std::max(y, inputBottom); } - const int x = (pageWidth - width) / 2; - const int y = pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - height + - metrics.keyboardVerticalOffset + (buttonOnly ? BUTTON_KEY_BOTTOM_SHIFT : 0); - return fui::Rect{static_cast(x), static_cast(y), static_cast(width), - static_cast(height)}; + return fui::Rect{0, static_cast(y), static_cast(pageWidth), + static_cast(hasTouch ? pageHeight - y : height)}; } void KeyboardEntryActivity::loop() { @@ -939,6 +949,16 @@ void KeyboardEntryActivity::render(RenderLock&&) { } const fui::Rect kbRect = keyboardRect(); + const int keysHeight = std::min(keyboardKeysHeight(metrics, currentLayout().rowCount, mappedInput.hasTouchHardware()), + static_cast(kbRect.height)); + const auto orientation = renderer.getOrientation(); + const bool sideHintsOverlapKeys = + deviceUsesSideButtonHintGutters(gpio) && (orientation == GfxRenderer::Orientation::LandscapeClockwise || + orientation == GfxRenderer::Orientation::LandscapeCounterClockwise); + const int sideInset = sideHintsOverlapKeys ? metrics.sideButtonHintsWidth + SIDE_HINT_CLEARANCE : 0; + const fui::Rect keysRect{static_cast(kbRect.x + sideInset), + static_cast(kbRect.y + (kbRect.height - keysHeight) / 2), + static_cast(kbRect.width - 2 * sideInset), static_cast(keysHeight)}; const int tipsLh = renderer.getLineHeight(SMALL_FONT_ID); const int underlineBottom = inputStartY + inputHeight + lineHeight + metrics.verticalSpacing + 4; @@ -1006,28 +1026,74 @@ void KeyboardEntryActivity::render(RenderLock&&) { fui::KeyboardProps props; const fui::KeyboardLayout& layout = currentLayout(); props.layout = &layout; + // Match the bottom row's outer edges to the ten-key letter row. Control-only + // rows otherwise fill the whole panel, even when their key weights shrink. + // cppcheck-suppress variableScope ; props.layout may point at compactLayout, which points at these rows + fui::KeyboardRow compactRows[5]; + fui::KeyboardKey compactBottomKeys[4]; + fui::KeyboardLayout compactLayout; + if (layoutId == fui::KeyboardLayoutId::QwertyEn && inputType != InputType::Url && !symbols && layout.rowCount == 5) { + const auto& bottomRow = layout.rows[4]; + const uint8_t spaceIndex = bottomRow.count >= 2 ? static_cast(bottomRow.count - 2) : 0; + if ((bottomRow.count == 3 || bottomRow.count == 4) && bottomRow.keys && + bottomRow.keys[0].kind == fui::KeyKind::Mode && bottomRow.keys[spaceIndex].kind == fui::KeyKind::Space && + bottomRow.keys[bottomRow.count - 1].kind == fui::KeyKind::Ok) { + constexpr int BOTTOM_UNIT_PX = 3; + constexpr int LETTER_ROW_UNITS = 20; // Ten English keys, two units each. + constexpr int LETTER_ROW_GAPS = 9; + const int gap = keyboardGap(metrics); + const int contentWidth = keysRect.width - props.padding.left - props.padding.right; + const int letterUnit = (contentWidth - LETTER_ROW_GAPS * gap) / LETTER_ROW_UNITS; + const int letterInset = (contentWidth - (LETTER_ROW_UNITS * letterUnit + LETTER_ROW_GAPS * gap)) / 2; + const int insetUnits = (letterInset + BOTTOM_UNIT_PX / 2) / BOTTOM_UNIT_PX; + const int bottomUnits = (contentWidth - (bottomRow.count - 1) * gap) / BOTTOM_UNIT_PX - 2 * insetUnits; + const int modeUnits = std::max( + (3 * bottomUnits + LETTER_ROW_UNITS / 2) / LETTER_ROW_UNITS, + (renderer.getTextWidth(UI_12_FONT_ID, tr(STR_KEY_MODE_SYMBOLS)) + 8 + BOTTOM_UNIT_PX - 1) / BOTTOM_UNIT_PX); + const int okUnits = + std::max((3 * bottomUnits + LETTER_ROW_UNITS / 2) / LETTER_ROW_UNITS, + (renderer.getTextWidth(UI_12_FONT_ID, tr(STR_OK_BUTTON)) + 8 + BOTTOM_UNIT_PX - 1) / BOTTOM_UNIT_PX); + const int langUnits = bottomRow.count == 4 ? (2 * bottomUnits + LETTER_ROW_UNITS / 2) / LETTER_ROW_UNITS : 0; + const int spaceUnits = bottomUnits - modeUnits - okUnits - langUnits; + if (spaceUnits > 0 && spaceUnits <= UINT8_MAX && modeUnits <= UINT8_MAX && okUnits <= UINT8_MAX && + langUnits <= UINT8_MAX) { + std::copy_n(layout.rows, layout.rowCount, compactRows); + std::copy_n(bottomRow.keys, bottomRow.count, compactBottomKeys); + compactRows[4].insetUnits = static_cast(insetUnits); + compactBottomKeys[0].widthUnits = static_cast(modeUnits); + if (bottomRow.count == 4) compactBottomKeys[1].widthUnits = static_cast(langUnits); + compactBottomKeys[spaceIndex].widthUnits = static_cast(spaceUnits); + compactBottomKeys[bottomRow.count - 1].widthUnits = static_cast(okUnits); + compactRows[4].keys = compactBottomKeys; + compactLayout = {compactRows, layout.rowCount}; + props.layout = &compactLayout; + } + } + } props.keyAction = ACTION_KEY; // one action id; loop() dispatches on key value props.okLabel = tr(STR_OK_BUTTON); - props.shiftLabel = tr(STR_KEY_SHIFT); // Match the label to the layer the mode key leads back from: the symbols // layer and the URL snippet panel both label it "abc" in the static tables. props.modeLabel = (symbols || (inputType == InputType::Url && urlPanel)) ? tr(STR_KEY_MODE_ABC) : tr(STR_KEY_MODE_SYMBOLS); props.inputMask = static_cast(fui::InputTouch | fui::InputLongPress); props.selectedIndex = cursorMode ? -1 : static_cast(selectedLogicalIndex()); - props.labelText.font = fui::GfxRendererTarget::FONT_BODY; + props.labelText.font = layoutId == fui::KeyboardLayoutId::ArabicAr && !symbols ? fui::GfxRendererTarget::FONT_SMALL + : fui::GfxRendererTarget::FONT_BODY; props.altText.font = fui::GfxRendererTarget::FONT_SMALL; - props.gap = static_cast(metrics.keyboardKeySpacing); + props.gap = props.rowGap = static_cast(keyboardGap(metrics)); + if (urlPanel) props.uniformKeyWidth = false; if (!mappedInput.hasTouchHardware()) { - props.rowGap = BUTTON_KEY_ROW_GAP; - props.altLabelGap = 0; + props.background = fui::Paint::none(); + props.altHintRightPadding = 5; } - props.padding = fui::Insets{0, 0, 0, 0}; + frame.target().fill(kbRect, props.background); // Fingers land low on the bottom row (occlusion) and there is no key below // to catch the miss — extend its hit band down to the button hints bar. - const int hintsTop = renderer.getScreenHeight() - metrics.buttonHintsHeight; - props.bottomHitOverflow = static_cast(std::max(0, hintsTop - (kbRect.y + kbRect.height))); - fui::keyboard(frame, kbRect, props); + const int bottomEdge = mappedInput.hasTouchHardware() ? renderer.getScreenHeight() + : renderer.getScreenHeight() - metrics.buttonHintsHeight; + props.bottomHitOverflow = static_cast(std::max(0, bottomEdge - keysRect.bottom())); + fui::keyboard(frame, keysRect, props); interactions.publish(); interactionsReady.store(true, std::memory_order_release); diff --git a/src/components/OptionPopup.h b/src/components/OptionPopup.h index ae6b4793f38..eebf5ed99ef 100644 --- a/src/components/OptionPopup.h +++ b/src/components/OptionPopup.h @@ -73,6 +73,8 @@ class OptionPopup { primaryOptionIndex = -1; popupNote = Note(); confirmationMode = true; + dividerAfterOption = -1; + selectionArrow = false; activate(currentIndex); } @@ -95,6 +97,9 @@ class OptionPopup { layoutValid = false; } + void setDividerAfterOption(const int index) { dividerAfterOption = index; } + void setSelectionArrow(const bool enabled) { selectionArrow = enabled; } + void show(const char* titleStr, const std::vector& options, int currentIndex, std::function onSelect, Note note = Note()) { title = titleStr; @@ -281,6 +286,24 @@ class OptionPopup { GUI.drawOptionPopup(renderer, title.c_str(), ownedStrings, selectedIndex, confirmationMode, tr(STR_CANCEL), tr(STR_SAVE), footerFocused, primaryOptionIndex, popupNote.boldLabel, popupNote.body, disabledOptions, renderLayout.firstOptionIndex); + const int visibleIndex = dividerAfterOption - renderLayout.firstOptionIndex; + if (visibleIndex >= 0 && visibleIndex + 1 < static_cast(renderLayout.options.size())) { + const auto& row = renderLayout.options[visibleIndex]; + const auto& next = renderLayout.options[visibleIndex + 1]; + const int y = (row.y + row.height + next.y) / 2; + const int inset = UITheme::getInstance().getMetrics().optionPopupInnerPadding; + renderer.drawLine(renderLayout.dialog.x + inset, y, renderLayout.dialog.x + renderLayout.dialog.width - inset, y); + } + const int selectedVisibleIndex = selectedIndex - renderLayout.firstOptionIndex; + if (selectionArrow && selectedVisibleIndex >= 0 && + selectedVisibleIndex < static_cast(renderLayout.options.size())) { + const auto& row = renderLayout.options[selectedVisibleIndex]; + const int x = row.x + 8; + const int y = row.y + row.height / 2; + const int xs[] = {x, x, x + 9}; + const int ys[] = {y - 6, y + 6, y}; + renderer.fillPolygon(xs, ys, 3, UITheme::getInstance().getMetrics().optionPopupSelectionLight); + } } bool isActive() const { return active; } @@ -432,6 +455,8 @@ class OptionPopup { Note popupNote; bool skipPostSelectionUpdate_ = false; int primaryOptionIndex = -1; + int dividerAfterOption = -1; + bool selectionArrow = false; ButtonNavigator buttonNavigator; mutable Layout layout; mutable bool layoutValid = false; @@ -468,6 +493,8 @@ class OptionPopup { onSaveCallback = nullptr; onCancelCallback = nullptr; primaryOptionIndex = -1; + dividerAfterOption = -1; + selectionArrow = false; } void activateSelection(MappedInputManager& input, const std::function& requestUpdate, diff --git a/src/components/TouchHeaderBackButton.cpp b/src/components/TouchHeaderBackButton.cpp index 8610a32164a..5848183788d 100644 --- a/src/components/TouchHeaderBackButton.cpp +++ b/src/components/TouchHeaderBackButton.cpp @@ -78,19 +78,20 @@ bool wasTapped(const MappedInputManager& input, const GfxRenderer& renderer) { } void draw(GfxRenderer& renderer, const Rect& header, const char* title, const bool readerContext, - const int rightReserve, const char* subtitle, const int verticalOffset) { + const int rightReserve, const char* subtitle, const int verticalOffset, const bool showStatus) { auto target = makeUiTarget(renderer); - draw(renderer, target, header, title, readerContext, rightReserve, subtitle, verticalOffset); + draw(renderer, target, header, title, readerContext, rightReserve, subtitle, verticalOffset, showStatus); } void draw(const GfxRenderer& renderer, fui::GfxRendererTarget& target, const Rect& header, const char* title, - const bool readerContext, const int rightReserve, const char* subtitle, const int verticalOffset) { + const bool readerContext, const int rightReserve, const char* subtitle, const int verticalOffset, + const bool showStatus) { Layout back = layout(header); const int offset = effectiveVerticalOffset(back, header, verticalOffset); back.iconRect.y += offset; // Keep the status row on the same baseline as Home. The optional offset only // adds breathing room below it for the navigation icon and title. - GUI.drawHeader(renderer, header, "", subtitle, readerContext); + GUI.drawHeader(renderer, header, "", subtitle, readerContext, showStatus); fui::TextStyle titleStyle = uiThemeTokens(target).titleText; titleStyle.align = fui::TextAlign::Left; diff --git a/src/components/TouchHeaderBackButton.h b/src/components/TouchHeaderBackButton.h index 391a57772b2..e37c111a954 100644 --- a/src/components/TouchHeaderBackButton.h +++ b/src/components/TouchHeaderBackButton.h @@ -26,10 +26,10 @@ Rect compactHeaderRect(const GfxRenderer& renderer); bool wasTapped(const MappedInputManager& input, const Rect& header); bool wasTapped(const MappedInputManager& input, const GfxRenderer& renderer); void draw(GfxRenderer& renderer, const Rect& header, const char* title, bool readerContext, int rightReserve = 0, - const char* subtitle = nullptr, int verticalOffset = TITLE_VERTICAL_OFFSET); + const char* subtitle = nullptr, int verticalOffset = TITLE_VERTICAL_OFFSET, bool showStatus = true); void draw(const GfxRenderer& renderer, freeink::ui::GfxRendererTarget& target, const Rect& header, const char* title, bool readerContext, int rightReserve = 0, const char* subtitle = nullptr, - int verticalOffset = TITLE_VERTICAL_OFFSET); + int verticalOffset = TITLE_VERTICAL_OFFSET, bool showStatus = true); void drawCompact(GfxRenderer& renderer, const char* title, bool readerContext = false, bool showDate = false, int verticalOffset = TITLE_VERTICAL_OFFSET); diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index d9fffcf0ce5..6dfe2424241 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -254,19 +255,30 @@ UIIcon UITheme::getFileIcon(const std::string& filename) { return File; } -int UITheme::getStatusBarHeight() { +int UITheme::getStatusBarHeight() { return getReaderStatusBarHeight(ReaderStatusBarPosition::Bottom); } + +int UITheme::getReaderStatusBarHeight(const ReaderStatusBarPosition position) { const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); - const auto statusBar = SETTINGS.statusBarSpec(); - // Reserve the clock lane independently of the current board so orientation - // and layout do not change when the same settings are used on another device. - return (statusBar.textLaneVisible(true) ? metrics.statusBarVerticalMargin : 0) + - (statusBar.showsProgressBar() ? statusBar.progressBarHeightPx + metrics.progressBarMarginTop : 0); + const auto config = SETTINGS.readerStatusBar(position); + const bool hasText = config.hasTextItems(halClock.isAvailable()); + const int progressSpace = config.progressBar != CrossPointSettings::HIDE_PROGRESS + ? static_cast((config.progressBarThickness + 1) * 2) + metrics.progressBarMarginTop + : 0; + return readerStatusBarTotalHeight(position, hasText, progressSpace, metrics.statusBarVerticalMargin); } -int UITheme::getProgressBarHeight() { +int UITheme::getProgressBarHeight() { return getReaderProgressBarHeight(ReaderStatusBarPosition::Bottom); } + +int UITheme::getReaderProgressBarHeight(const ReaderStatusBarPosition position) { const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); - const auto statusBar = SETTINGS.statusBarSpec(); - return statusBar.showsProgressBar() ? statusBar.progressBarHeightPx + metrics.progressBarMarginTop : 0; + const auto config = SETTINGS.readerStatusBar(position); + return config.progressBar != CrossPointSettings::HIDE_PROGRESS + ? static_cast((config.progressBarThickness + 1) * 2) + metrics.progressBarMarginTop + : 0; +} + +int UITheme::getTopStatusBarY(const GfxRenderer& renderer) { + return getInstance().getMetrics().topPadding + getTopStatusBarInset(renderer); } int UITheme::getTopStatusBarInset(const GfxRenderer& renderer) { diff --git a/src/components/UITheme.h b/src/components/UITheme.h index c8872e0f6bb..11cbb8a3f3b 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -49,8 +49,12 @@ class UITheme { static UIIcon getFileIcon(const std::string& filename); static int getStatusBarHeight(); static int getProgressBarHeight(); + static int getReaderStatusBarHeight(ReaderStatusBarPosition position); + static int getReaderProgressBarHeight(ReaderStatusBarPosition position); // Device-specific top offset for the clock, battery, and reserved status-bar lane. static int getTopStatusBarInset(const GfxRenderer& renderer); + // Absolute screen origin shared by Home, menu headers, and the reader. + static int getTopStatusBarY(const GfxRenderer& renderer); private: const ThemeMetrics* currentMetrics; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 86573431def..f53de507b21 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -126,7 +126,7 @@ void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const b int BaseTheme::homeHeaderClockTextYOffset(const GfxRenderer& renderer) { const auto& metrics = UITheme::getInstance().getMetrics(); - const int statusBarHeight = std::max(UITheme::getStatusBarHeight(), metrics.statusBarVerticalMargin); + const int statusBarHeight = metrics.statusBarVerticalMargin; const int centeredClockY = (statusBarHeight - renderer.getLineHeight(SMALL_FONT_ID)) / 2; return homeHeaderTopInset - centeredClockY; } @@ -416,7 +416,7 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, } void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle, - const bool readerContext) const { + const bool readerContext, const bool showStatus) const { namespace fui = freeink::ui; const auto spec = uiScaleSpec(); fui::GfxRendererFrame<1> ui(renderer, spec.smallFontId, spec.bodyFontId, spec.titleFontId); @@ -426,15 +426,14 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t const fui::Rect band{static_cast(rect.x), static_cast(rect.y), static_cast(rect.width), static_cast(rect.height)}; - const bool showHeaderClock = halClock.isAvailable() && (readerContext ? SETTINGS.shouldShowClockInReader() - : SETTINGS.shouldShowClockOutsideReader()); + const bool showHeaderClock = showStatus && halClock.isAvailable() && SETTINGS.shouldShowClockOutsideReader(); const bool showBatteryPercentage = - SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; + showStatus && SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; const uint16_t percentage = powerManager.getBatteryPercentage(); char percentText[8]; snprintf(percentText, sizeof(percentText), "%u%%", static_cast(percentage)); constexpr int16_t batteryNubWidth = 2; - int16_t batteryReserve = static_cast(metrics.batteryWidth + batteryNubWidth); + int16_t batteryReserve = showStatus ? static_cast(metrics.batteryWidth + batteryNubWidth) : 0; if (showBatteryPercentage) { batteryReserve = static_cast( batteryReserve + batteryPercentSpacing + @@ -456,13 +455,9 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t const bool roundedRaffCompactHeader = !readerContext && SETTINGS.uiTheme == CrossPointSettings::UI_THEME::ROUNDEDRAFF && rect.height != metrics.homeTopPadding; - const bool lyraHeader = SETTINGS.uiTheme == CrossPointSettings::UI_THEME::LYRA || - SETTINGS.uiTheme == CrossPointSettings::UI_THEME::LYRA_3_COVERS || - SETTINGS.uiTheme == CrossPointSettings::UI_THEME::LYRA_CAROUSEL; const bool roundedRaffHeader = !readerContext && SETTINGS.uiTheme == CrossPointSettings::UI_THEME::ROUNDEDRAFF; - const int clockYOffset = roundedRaffHeader - ? roundedRaffHeaderClockYOffset - : (!hasVisibleTitle && !readerContext ? homeHeaderClockTextYOffset(renderer) : 0); + const int clockYOffset = + roundedRaffHeader ? roundedRaffHeaderClockYOffset : (!readerContext ? homeHeaderClockTextYOffset(renderer) : 0); if (batteryDetached) { const int titleLineHeight = ui.target.lineHeight(fui::GfxRendererTarget::FONT_TITLE); const int titleTop = static_cast(band.height) - tokens.headerUnderline - tokens.spaceMd - titleLineHeight; @@ -481,17 +476,15 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t } fui::header(ui.frame, band, props); - const int16_t batteryEdgeInset = batteryDetached ? 12 : tokens.headerSidePadding; + if (!showStatus) return; + + const int16_t batteryEdgeInset = batteryDetached ? StatusBarMetrics::sideInset : tokens.headerSidePadding; const int16_t batteryX = batteryLeft ? static_cast(band.x + batteryEdgeInset) : static_cast(band.right() - batteryEdgeInset - batteryReserve); - // Lyra places its battery in the top status lane. Align the percentage text - // to the clock's text row, while the shared icon helper keeps the glyph - // vertically aligned with that label. + // Shared detached headers use the same status row as Dashboard Home. const int16_t batteryY = [&] { - if (batteryDetached && lyraHeader) { - const int statusBarHeight = std::max(UITheme::getStatusBarHeight(), metrics.statusBarVerticalMargin); - return static_cast(rect.y + (statusBarHeight - renderer.getLineHeight(SMALL_FONT_ID)) / 2 + - UITheme::getTopStatusBarInset(renderer) + clockYOffset); + if (batteryDetached && SETTINGS.uiTheme != CrossPointSettings::UI_THEME::ROUNDEDRAFF) { + return static_cast(rect.y + UITheme::getTopStatusBarInset(renderer) + homeHeaderTopInset); } // RoundedRaff's Home header is taller than ordinary headers. Shift compact @@ -502,8 +495,10 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t ? detachedHeaderBatteryTopInset : (roundedRaffCompactHeader ? std::max(0, (metrics.homeTopPadding - metrics.headerHeight) / 2) : 0))); }(); - const int16_t batteryIconX = - batteryLeft ? batteryX : static_cast(batteryX + batteryReserve - metrics.batteryWidth - batteryNubWidth); + const int16_t batteryIconX = batteryLeft + ? batteryX + : static_cast(band.right() - batteryEdgeInset - metrics.batteryWidth - + (batteryDetached ? 0 : batteryNubWidth)); const Rect batteryRect{batteryIconX, batteryY, metrics.batteryWidth, metrics.batteryHeight}; if (batteryLeft) { drawBatteryLeft(renderer, batteryRect, showBatteryPercentage); @@ -895,177 +890,182 @@ void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou renderer.displayBuffer(HalDisplay::FAST_REFRESH); } -void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, - const int pageCount, const char* title, const int paddingBottom, const int textYOffset, - const bool isPageBookmarked, const char* timeLeftLabel, const bool darkMode, - const float chapterProgressPercent, const int stableCurrentPage, - const int stablePageCount, const bool showProgress, const bool pageCountEstimated) const { - const bool foregroundBlack = !darkMode; - auto metrics = UITheme::getInstance().getMetrics(); - int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; - renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, - &orientedMarginLeft); - const auto statusBar = SETTINGS.statusBarSpec(); - const bool showStatusBarTextLane = statusBar.textLaneVisible(halClock.isAvailable()); - - // Draw Progress Text - const auto screenHeight = renderer.getScreenHeight(); - auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4; - int progressTextWidth = 0; - - const bool showStablePageNumbers = statusBar.showStablePageNumbers && stableCurrentPage > 0 && stablePageCount > 0; - if (showProgress && (statusBar.showBookProgressPercent || statusBar.showChapterPageCount || showStablePageNumbers)) { - // Right aligned text for progress counter - char progressStr[48]; - const int percentageDecimals = - SETTINGS.statusBarBookPercentageFormat < CrossPointSettings::BOOK_PERCENTAGE_FORMAT_COUNT - ? SETTINGS.statusBarBookPercentageFormat - : CrossPointSettings::BOOK_PERCENTAGE_WHOLE; - // Draw the estimate marker separately so it is legible on lower-PPI displays. - const bool showEstimate = pageCountEstimated && statusBar.showChapterPageCount; - - if (statusBar.showChapterPageCount && showStablePageNumbers && statusBar.showBookProgressPercent) { - snprintf(progressStr, sizeof(progressStr), "%d/%d %d/%d %.*f%%", currentPage, pageCount, stableCurrentPage, - stablePageCount, percentageDecimals, bookProgress); - } else if (statusBar.showChapterPageCount && showStablePageNumbers) { - snprintf(progressStr, sizeof(progressStr), "%d/%d %d/%d", currentPage, pageCount, stableCurrentPage, - stablePageCount); - } else if (statusBar.showChapterPageCount && statusBar.showBookProgressPercent) { - snprintf(progressStr, sizeof(progressStr), "%d/%d %.*f%%", currentPage, pageCount, percentageDecimals, - bookProgress); - } else if (showStablePageNumbers && statusBar.showBookProgressPercent) { - snprintf(progressStr, sizeof(progressStr), "%d/%d %.*f%%", stableCurrentPage, stablePageCount, - percentageDecimals, bookProgress); - } else if (statusBar.showBookProgressPercent) { - snprintf(progressStr, sizeof(progressStr), "%.*f%%", percentageDecimals, bookProgress); - } else if (showStablePageNumbers) { - snprintf(progressStr, sizeof(progressStr), "%d/%d", stableCurrentPage, stablePageCount); - } else { - snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage, pageCount); +void BaseTheme::drawReaderStatusBar(GfxRenderer& renderer, const ReaderStatusBarPosition position, + const ReaderStatusBarContent& content, + const ReaderStatusBarConfig* overrideConfig) const { + const ReaderStatusBarConfig config = overrideConfig ? *overrideConfig : SETTINGS.readerStatusBar(position); + const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); + const bool top = position == ReaderStatusBarPosition::Top; + const bool foregroundBlack = !content.darkMode; + const bool clockAvailable = halClock.isAvailable() || content.previewClock != nullptr; + const bool hasText = config.hasTextItems(clockAvailable) || (!top && content.autoTurnLabel != nullptr); + const int progressHeight = config.progressBar != CrossPointSettings::HIDE_PROGRESS + ? static_cast((config.progressBarThickness + 1) * 2) + : 0; + const int progressSpace = progressHeight > 0 ? progressHeight + metrics.progressBarMarginTop : 0; + const int textHeight = hasText ? metrics.statusBarVerticalMargin : 0; + const int totalHeight = readerStatusBarTotalHeight(position, hasText, progressSpace, metrics.statusBarVerticalMargin); + if (totalHeight <= 0) return; + + int marginTop, marginRight, marginBottom, marginLeft; + renderer.getOrientedViewableTRBL(&marginTop, &marginRight, &marginBottom, &marginLeft); + const int screenWidth = renderer.getScreenWidth(); + const int edgeY = content.previewOriginY >= 0 + ? content.previewOriginY + : (top ? UITheme::getTopStatusBarY(renderer) + content.edgePadding + : renderer.getScreenHeight() - marginBottom - totalHeight - content.edgePadding); + const int textY = edgeY + (top ? progressSpace : 0) + + (hasText ? (top ? ReaderStatusBarConfig::TOP_TEXT_INSET + : (textHeight - renderer.getLineHeight(SMALL_FONT_ID)) / 2) + : 0); + + if (progressHeight > 0 && content.showProgress) { + const float percent = + config.progressBar == CrossPointSettings::BOOK_PROGRESS + ? content.bookProgress + : (content.chapterProgress >= 0 + ? content.chapterProgress + : (content.chapterPageCount > 0 ? 100.0f * content.chapterPage / content.chapterPageCount : 0.0f)); + const int bookmarkOffset = !top && !hasText && content.bookmarked + ? metrics.statusBarHorizontalMargin + 1 + ReaderStatusBarConfig::BOOKMARK_WIDTH + 4 + : 0; + const int barWidth = std::max(0, screenWidth - marginLeft - marginRight - bookmarkOffset); + const int filled = static_cast(barWidth * std::clamp(percent, 0.0f, 100.0f) / 100.0f); + const int barY = top ? edgeY : edgeY + totalHeight - progressHeight; + renderer.fillRect(marginLeft + bookmarkOffset, barY, filled, progressHeight, foregroundBlack); + } + int bookmarkReserve = 0; + if (!top && content.bookmarked) { + constexpr int bookmarkWidth = ReaderStatusBarConfig::BOOKMARK_WIDTH; + constexpr int bookmarkHeight = ReaderStatusBarConfig::BOOKMARK_HEIGHT; + const int bookmarkY = hasText ? textY + (metrics.batteryHeight - bookmarkHeight) / 2 + 5 : edgeY; + const int bookmarkX = marginLeft + metrics.statusBarHorizontalMargin + 1; + renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, foregroundBlack); + const int notchX[3] = {bookmarkX, bookmarkX + bookmarkWidth, bookmarkX + bookmarkWidth / 2}; + const int notchY[3] = {bookmarkY + bookmarkHeight, bookmarkY + bookmarkHeight, bookmarkY + bookmarkHeight - 5}; + renderer.fillPolygon(notchX, notchY, 3, content.darkMode); + bookmarkReserve = bookmarkWidth + 4; + } + if (!hasText) return; + + const bool batteryPercent = SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_NEVER; + const auto itemText = [&](const ReaderStatusBarItem item, char* scratch, const size_t len) -> const char* { + switch (item) { + case ReaderStatusBarItem::Clock: + if (content.previewClock) return content.previewClock; + return halClock.isAvailable() && + halClock.formatTime(scratch, len, SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1) + ? scratch + : nullptr; + case ReaderStatusBarItem::TimeLeftBook: + return content.timeLeftBook; + case ReaderStatusBarItem::TimeLeftChapter: + return content.timeLeftChapter; + case ReaderStatusBarItem::ChapterPageCount: + if (!content.showProgress || content.chapterPageCount <= 0) return nullptr; + snprintf(scratch, len, "%s%d/%d", content.pageCountEstimated ? "~" : "", content.chapterPage, + content.chapterPageCount); + return scratch; + case ReaderStatusBarItem::StablePageNumber: + if (!content.showProgress || content.stablePageCount <= 0 || content.stablePage <= 0) return nullptr; + snprintf(scratch, len, "%d/%d", content.stablePage, content.stablePageCount); + return scratch; + case ReaderStatusBarItem::BookProgressPercentage: + if (!content.showProgress) return nullptr; + snprintf(scratch, len, "%.*f%%", static_cast(config.percentageFormat), content.bookProgress); + return scratch; + case ReaderStatusBarItem::TitleBook: + return content.bookTitle; + case ReaderStatusBarItem::TitleChapter: + return content.chapterTitle; + default: + return nullptr; } - - progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); - const int estimateWidth = showEstimate ? renderer.getTextWidth(UI_10_FONT_ID, "~") : 0; - constexpr int estimateGap = 2; - const int estimateSpacing = showEstimate ? estimateGap : 0; - const int progressX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight - - estimateWidth - estimateSpacing - progressTextWidth; - if (showEstimate) { - const int estimateY = textY + (renderer.getLineHeight(SMALL_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2; - renderer.drawText(UI_10_FONT_ID, progressX, estimateY, "~"); + }; + const auto measureItem = [&](const ReaderStatusBarItem item, const int maxWidth) { + if (item == ReaderStatusBarItem::Battery) { + int width = metrics.batteryWidth; + if (batteryPercent) { + char percent[8]; + snprintf(percent, sizeof(percent), "%u%%", static_cast(powerManager.getBatteryPercentage())); + width += batteryPercentSpacing + renderer.getTextWidth(SMALL_FONT_ID, percent); + } + return width; } - renderer.drawText(SMALL_FONT_ID, progressX + estimateWidth + estimateSpacing, textY, progressStr); - progressTextWidth += estimateWidth + estimateSpacing; - } - - // Draw Progress Bar - if (showProgress && statusBar.showsProgressBar()) { - const int progressBarMaxWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; - const int progressBarY = - renderer.getScreenHeight() - orientedMarginBottom - statusBar.progressBarHeightPx - paddingBottom; - size_t progress; - if (statusBar.progressBarMode == CrossPointSettings::STATUS_BAR_PROGRESS_BAR::BOOK_PROGRESS) { - progress = static_cast(bookProgress); - } else if (chapterProgressPercent >= 0.0f) { - progress = static_cast(std::clamp(chapterProgressPercent, 0.0f, 100.0f)); - } else { - // Chapter progress - progress = (pageCount > 0) ? (static_cast(currentPage) / pageCount) * 100 : 0; + char scratch[48]; + const char* value = itemText(item, scratch, sizeof(scratch)); + return value && value[0] ? std::min(maxWidth, renderer.getTextWidth(SMALL_FONT_ID, value)) : 0; + }; + const auto drawItem = [&](const ReaderStatusBarItem item, const int x, const int allowedWidth, + const bool alignRight) { + if (item == ReaderStatusBarItem::Battery) { + if (allowedWidth < metrics.batteryWidth) return; + char percent[8]; + snprintf(percent, sizeof(percent), "%u%%", static_cast(powerManager.getBatteryPercentage())); + const bool showPercent = batteryPercent && allowedWidth >= metrics.batteryWidth + batteryPercentSpacing + + renderer.getTextWidth(SMALL_FONT_ID, percent); + if (top && alignRight) { + drawBatteryRight( + renderer, Rect{x + allowedWidth - metrics.batteryWidth, textY, metrics.batteryWidth, metrics.batteryHeight}, + showPercent, foregroundBlack); + } else { + drawBatteryLeft(renderer, Rect{x, textY, metrics.batteryWidth, metrics.batteryHeight}, showPercent, + foregroundBlack); + } + return; } - const int barWidth = progressBarMaxWidth * progress / 100; - renderer.fillRect(orientedMarginLeft, progressBarY, barWidth, statusBar.progressBarHeightPx, foregroundBlack); + char scratch[48]; + const char* value = itemText(item, scratch, sizeof(scratch)); + if (!value || !value[0] || allowedWidth <= 0) return; + std::string clipped; + int width = renderer.getTextWidth(SMALL_FONT_ID, value); + if (width > allowedWidth) { + clipped = renderer.truncatedText(SMALL_FONT_ID, value, allowedWidth); + value = clipped.c_str(); + width = renderer.getTextWidth(SMALL_FONT_ID, value); + } + renderer.drawText(SMALL_FONT_ID, x + (alignRight ? allowedWidth - width : 0), textY, value, foregroundBlack); + }; + + constexpr int itemGap = 8; + const int leftEdge = + top ? std::max(marginLeft, StatusBarMetrics::sideInset) : marginLeft + metrics.statusBarHorizontalMargin + 1; + const int rightEdge = screenWidth - (top ? std::max(marginRight, StatusBarMetrics::sideInset) + : marginRight + metrics.statusBarHorizontalMargin); + const int available = std::max(0, rightEdge - leftEdge); + + std::array widths{}; + for (unsigned i = ReaderStatusBarConfig::LEFT_FIRST; i <= ReaderStatusBarConfig::LEFT_THIRD; ++i) { + widths[i] = measureItem(config.slots[i], available); } - - // Bookmark icon: drawn at the far left of the status bar when the current page is bookmarked. - // Battery (and future left-side indicators) are offset to the right of it. - static constexpr int bmIconW = 9; - static constexpr int bmIconH = 14; - static constexpr int bmIconGap = 4; - static constexpr int bmNotchDepth = 5; - static constexpr int statusItemGap = 8; - const int leftClusterX = metrics.statusBarHorizontalMargin + orientedMarginLeft + 1; - const bool showBookmark = showStatusBarTextLane && isPageBookmarked; - const int bmTotalWidth = showBookmark ? (bmIconW + bmIconGap) : 0; - - if (showBookmark) { - const int bmX = leftClusterX; - // +5 compensates for the battery nub drawn above the rect origin by drawBatteryLeft, - // which shifts the battery body's visual center below the mathematical rect center. - const int bmY = textY + (metrics.batteryHeight - bmIconH) / 2 + 5; - renderer.fillRect(bmX, bmY, bmIconW, bmIconH, foregroundBlack); - const int xNotch[3] = {bmX, bmX + bmIconW, bmX + bmIconW / 2}; - const int yNotch[3] = {bmY + bmIconH, bmY + bmIconH, bmY + bmIconH - bmNotchDepth}; - renderer.fillPolygon(xNotch, yNotch, 3, darkMode); + for (unsigned i = ReaderStatusBarConfig::RIGHT_FIRST; i <= ReaderStatusBarConfig::RIGHT_THIRD; ++i) { + widths[i] = measureItem(config.slots[i], available); } - - // Draw Battery - const bool showBatteryPercentage = statusBar.showBatteryPercent; - int leftClusterWidth = bmTotalWidth; - if (statusBar.showBattery) { - GUI.drawBatteryLeft(renderer, Rect{leftClusterX + bmTotalWidth, textY, metrics.batteryWidth, metrics.batteryHeight}, - showBatteryPercentage, foregroundBlack); - int batteryWidth = metrics.batteryWidth; - if (showBatteryPercentage) { - char batteryPercent[8]; - snprintf(batteryPercent, sizeof(batteryPercent), "%u%%", - static_cast(powerManager.getBatteryPercentage())); - batteryWidth += batteryPercentSpacing + renderer.getTextWidth(SMALL_FONT_ID, batteryPercent); - } - leftClusterWidth += batteryWidth; + fitReaderStatusBarSideWidths(widths, available, bookmarkReserve, itemGap); + const auto placement = layoutReaderStatusBarItems(leftEdge, rightEdge, widths, bookmarkReserve, itemGap); + for (unsigned i = ReaderStatusBarConfig::LEFT_FIRST; i <= ReaderStatusBarConfig::LEFT_THIRD; ++i) { + if (widths[i] > 0) drawItem(config.slots[i], placement.x[i], widths[i], false); } - - const bool hasTimeLeftLabel = timeLeftLabel != nullptr && timeLeftLabel[0] != '\0'; - if (hasTimeLeftLabel) { - const bool hasLeftItem = leftClusterWidth > 0; - const int timeLeftX = leftClusterX + leftClusterWidth + (hasLeftItem ? statusItemGap : 0); - renderer.drawText(SMALL_FONT_ID, timeLeftX, textY, timeLeftLabel, foregroundBlack); - const int timeLeftWidth = renderer.getTextWidth(SMALL_FONT_ID, timeLeftLabel); - leftClusterWidth += (hasLeftItem ? statusItemGap : 0) + timeLeftWidth; + for (unsigned i = ReaderStatusBarConfig::RIGHT_FIRST; i <= ReaderStatusBarConfig::RIGHT_THIRD; ++i) { + if (widths[i] > 0) drawItem(config.slots[i], placement.x[i], widths[i], true); } - // Draw Title - if (title && title[0] != '\0') { - textY -= textYOffset; - // Centered chapter title text - // Page width minus existing content with 30px padding on each side - const int rendererableScreenWidth = - renderer.getScreenWidth() - (metrics.statusBarHorizontalMargin * 2) - orientedMarginLeft - orientedMarginRight; - - const int titleMarginLeft = leftClusterWidth + 30; - const int titleMarginRight = progressTextWidth + 30; - - // Attempt to center title on the screen, but if title is too wide then later we will center it within the - // available space. - int titleMarginLeftAdjusted = std::max(titleMarginLeft, titleMarginRight); - int availableTitleSpace = rendererableScreenWidth - 2 * titleMarginLeftAdjusted; - - int titleWidth; - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title); - if (titleWidth > availableTitleSpace) { - // Not enough space to center on the screen, center it within the remaining space instead - availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight; - titleMarginLeftAdjusted = titleMarginLeft; - } - // Only the overflow path needs storage, and it must outlive the drawText below. - std::string truncated; - if (titleWidth > availableTitleSpace) { - truncated = renderer.truncatedText(SMALL_FONT_ID, title, availableTitleSpace); - title = truncated.c_str(); - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title); + const int centerWidth = placement.centerRight - placement.centerLeft; + const auto centerItem = + !top && content.autoTurnLabel ? ReaderStatusBarItem::TitleChapter : config.slots[ReaderStatusBarConfig::CENTER]; + if (centerWidth > 0) { + const int width = measureItem(centerItem, centerWidth); + if (width > 0) { + const int centeredX = std::clamp((screenWidth - width) / 2, placement.centerLeft, placement.centerRight - width); + drawItem(centerItem, centeredX, width, false); } - - renderer.drawText(SMALL_FONT_ID, - titleMarginLeftAdjusted + metrics.statusBarHorizontalMargin + orientedMarginLeft + - (availableTitleSpace - titleWidth) / 2, - textY, title, foregroundBlack); } } void BaseTheme::drawTopStatusBarClock(const GfxRenderer& renderer, int topY, const char* previewTime, const bool readerContext, const int textYOffset, const bool darkMode, const bool forceVisible) const { - if (!forceVisible && - !(readerContext ? SETTINGS.shouldShowClockInReader() : SETTINGS.shouldShowClockOutsideReader())) { + if (!forceVisible && !SETTINGS.shouldShowClockOutsideReader()) { return; } @@ -1082,24 +1082,17 @@ void BaseTheme::drawTopStatusBarClock(const GfxRenderer& renderer, int topY, con } const auto& metrics = UITheme::getInstance().getMetrics(); - const int statusBarHeight = std::max(UITheme::getStatusBarHeight(), metrics.statusBarVerticalMargin); + const int statusBarHeight = metrics.statusBarVerticalMargin; if (statusBarHeight <= 0) { return; } - int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; - renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, - &orientedMarginLeft); - (void)orientedMarginRight; - (void)orientedMarginBottom; - (void)orientedMarginLeft; - const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, timeText); const int lineHeight = renderer.getLineHeight(SMALL_FONT_ID); const int textX = (renderer.getScreenWidth() - textWidth) / 2; const int effectiveTextYOffset = textYOffset + UITheme::getTopStatusBarInset(renderer) + (readerContext ? homeHeaderClockTextYOffset(renderer) : 0); - const int baseTopY = topY >= 0 ? topY : orientedMarginTop + metrics.topPadding; + const int baseTopY = topY >= 0 ? topY : metrics.topPadding; const int textY = baseTopY + (statusBarHeight - lineHeight) / 2 + effectiveTextYOffset; renderer.drawText(SMALL_FONT_ID, textX, textY, timeText, !darkMode); } diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 64b04437439..c563822288d 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -6,6 +6,8 @@ #include #include +#include "util/ReaderStatusBarConfig.h" + class GfxRenderer; struct RecentBook; struct BookReadingStats; @@ -25,6 +27,27 @@ struct TabInfo { bool selected; }; +struct ReaderStatusBarContent { + float bookProgress = 0; + float chapterProgress = -1; + int chapterPage = 0; + int chapterPageCount = 0; + int stablePage = 0; + int stablePageCount = 0; + const char* bookTitle = nullptr; + const char* chapterTitle = nullptr; + const char* timeLeftBook = nullptr; + const char* timeLeftChapter = nullptr; + const char* previewClock = nullptr; + const char* autoTurnLabel = nullptr; + bool bookmarked = false; + bool showProgress = true; + bool pageCountEstimated = false; + bool darkMode = false; + int edgePadding = 0; + int previewOriginY = -1; +}; + enum class ThemeTabBarAppearance : uint8_t { Pill, BorderedText, @@ -36,6 +59,7 @@ enum class ThemeTabBarAppearance : uint8_t { namespace StatusBarMetrics { constexpr int batteryWidth = 15; constexpr int batteryHeight = 12; +constexpr int sideInset = 12; } // namespace StatusBarMetrics struct ThemeMetrics { @@ -262,7 +286,7 @@ class BaseTheme { const std::function& isHeader = nullptr, int rowHeightScale = 1, bool showSelection = true) const; virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle = nullptr, - bool readerContext = false) const; + bool readerContext = false, bool showStatus = true) const; virtual void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, const char* rightLabel = nullptr) const; virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, @@ -289,12 +313,9 @@ class BaseTheme { int firstOptionIndex = -1) const; virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const; // title is borrowed and must stay alive for the call; pass nullptr or "" for none. - virtual void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, - const int pageCount, const char* title, const int paddingBottom = 0, - const int textYOffset = 0, const bool isPageBookmarked = false, - const char* timeLeftLabel = nullptr, bool darkMode = false, - float chapterProgressPercent = -1.0f, int stableCurrentPage = 0, int stablePageCount = 0, - bool showProgress = true, bool pageCountEstimated = false) const; + virtual void drawReaderStatusBar(GfxRenderer& renderer, ReaderStatusBarPosition position, + const ReaderStatusBarContent& content, + const ReaderStatusBarConfig* overrideConfig = nullptr) const; virtual void drawTopStatusBarClock(const GfxRenderer& renderer, int topY = -1, const char* previewTime = nullptr, bool readerContext = true, int textYOffset = 0, bool darkMode = false, bool forceVisible = false) const; @@ -309,7 +330,7 @@ class BaseTheme { // Shared constants and helpers for battery drawing (used by all themes) static constexpr int batteryPercentSpacing = 4; - static constexpr int homeHeaderTopInset = 2; + static constexpr int homeHeaderTopInset = ReaderStatusBarConfig::TOP_TEXT_INSET; static int homeHeaderClockTextYOffset(const GfxRenderer& renderer); static Rect buttonMenuTouchTarget(Rect rowRect, Rect menuRect, bool isLastItem, int rowSpacing); static void drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, diff --git a/src/components/themes/minimal/MinimalTheme.cpp b/src/components/themes/minimal/MinimalTheme.cpp index 1996338eb2b..e5e0b9bc77d 100644 --- a/src/components/themes/minimal/MinimalTheme.cpp +++ b/src/components/themes/minimal/MinimalTheme.cpp @@ -410,29 +410,34 @@ void drawBookCover(const GfxRenderer& renderer, const Rect& coverRect, const Rec } // namespace void MinimalTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle, - const bool readerContext) const { + const bool readerContext, const bool showStatus) const { (void)subtitle; + (void)readerContext; renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false); const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; - const int batteryX = rect.x + rect.width - 12 - MinimalMetrics::values.batteryWidth; - const int batteryY = rect.y + homeHeaderTopInset + UITheme::getTopStatusBarInset(renderer); - drawBatteryRight(renderer, - Rect{batteryX, batteryY, MinimalMetrics::values.batteryWidth, MinimalMetrics::values.batteryHeight}, - showBatteryPercentage); + const int batteryX = rect.x + rect.width - StatusBarMetrics::sideInset - MinimalMetrics::values.batteryWidth; + const int batteryY = rect.y + UITheme::getTopStatusBarInset(renderer) + homeHeaderTopInset; + if (showStatus) { + drawBatteryRight( + renderer, Rect{batteryX, batteryY, MinimalMetrics::values.batteryWidth, MinimalMetrics::values.batteryHeight}, + showBatteryPercentage); + } if (title) { constexpr int titleInsetX = 12; - const int maxTitleWidth = batteryX - rect.x - titleInsetX - MinimalMetrics::values.contentSidePadding; + const int titleRight = showStatus ? batteryX : rect.x + rect.width - 12; + const int maxTitleWidth = titleRight - rect.x - titleInsetX - MinimalMetrics::values.contentSidePadding; auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title, maxTitleWidth, EpdFontFamily::BOLD); renderer.drawText(UI_12_FONT_ID, rect.x + titleInsetX, rect.y + MinimalMetrics::values.batteryBarHeight + 3, truncatedTitle.c_str(), true, EpdFontFamily::BOLD); renderer.drawLine(rect.x, rect.y + rect.height - 3, rect.x + rect.width - 1, rect.y + rect.height - 3, 3, true); } - drawTopStatusBarClock(renderer, rect.y, nullptr, readerContext, - title == nullptr && !readerContext ? homeHeaderClockTextYOffset(renderer) : 0); + if (showStatus) { + drawTopStatusBarClock(renderer, rect.y, nullptr, false, homeHeaderClockTextYOffset(renderer)); + } } void MinimalTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, diff --git a/src/components/themes/minimal/MinimalTheme.h b/src/components/themes/minimal/MinimalTheme.h index 76a84fa35b1..bd55ed3b4da 100644 --- a/src/components/themes/minimal/MinimalTheme.h +++ b/src/components/themes/minimal/MinimalTheme.h @@ -44,7 +44,7 @@ class MinimalTheme : public LyraTheme { bool allowTwoLineTitles = true); void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle = nullptr, - bool readerContext = false) const override; + bool readerContext = false, bool showStatus = true) const override; void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected) const override; bool tabIndexFromPoint(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, int x, int y, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.cpp b/src/components/themes/roundedraff/RoundedRaffTheme.cpp index 5bbe4796eb7..82f83eded2e 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.cpp +++ b/src/components/themes/roundedraff/RoundedRaffTheme.cpp @@ -55,14 +55,14 @@ void drawScrollBar(const GfxRenderer& renderer, Rect rect, int itemCount, int pa int coverWidth = 0; void RoundedRaffTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle, - const bool readerContext) const { + const bool readerContext, const bool showStatus) const { // Home screen header is custom-rendered in drawRecentBookCover. if (title == nullptr) { const int clockYOffset = readerContext ? 0 : kHeaderClockYOffset; - drawTopStatusBarClock(renderer, rect.y, nullptr, readerContext, clockYOffset); + if (showStatus) drawTopStatusBarClock(renderer, rect.y, nullptr, readerContext, clockYOffset); return; } - BaseTheme::drawHeader(renderer, rect, title, subtitle, readerContext); + BaseTheme::drawHeader(renderer, rect, title, subtitle, readerContext, showStatus); } void RoundedRaffTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.h b/src/components/themes/roundedraff/RoundedRaffTheme.h index 5d10429c835..c38e44d47cb 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.h +++ b/src/components/themes/roundedraff/RoundedRaffTheme.h @@ -89,7 +89,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, class RoundedRaffTheme : public BaseTheme { public: void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle = nullptr, - bool readerContext = false) const override; + bool readerContext = false, bool showStatus = true) const override; void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected) const override; bool tabIndexFromPoint(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, int x, int y, diff --git a/src/main.cpp b/src/main.cpp index ffdde17c384..132a2d300b5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -606,6 +606,9 @@ bool handleGlobalPowerButtonAction(const CrossPointSettings::SHORT_PWRBTN action } activityManager.goToHotspotFileTransfer(); return true; + case CrossPointSettings::SHORT_PWRBTN::LIBRARY: + activityManager.goToLibrary(); + return true; case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FRONTLIGHT: { if (!Frontlight.present()) return false; const bool lightOn = !Frontlight.isOn(); @@ -690,6 +693,8 @@ CrossPointSettings::SHORT_PWRBTN chordPowerAction(const ButtonShortcutController return Power::SYNC_PROGRESS; case Chord::NearbyPositionSync: return Power::NEARBY_POSITION_SYNC; + case Chord::Library: + return Power::LIBRARY; case Chord::FileTransfer: return Power::FILE_TRANSFER; case Chord::CalibreWireless: @@ -1938,7 +1943,18 @@ void loop() { // Add delay at the end of the loop to prevent tight spinning // When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response // Otherwise, use longer delay to save power - if (activityManager.skipLoopDelay()) { + bool skipLoopDelay = false; + { + // Reader scheduling inspects state also owned by the render task. Never wait + // here: the input loop must stay available while a page is being rendered. + RenderLock lock(RenderLock::Mode::Try); + if (!lock.ownsLock()) { + delay(10); + return; + } + skipLoopDelay = activityManager.skipLoopDelay(); + } + if (skipLoopDelay) { powerManager.setPowerSaving(false); // Make sure we're at full performance when skipLoopDelay is requested yield(); // Give FreeRTOS a chance to run tasks, but return immediately } else { diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 99743a285ea..001d6fe1e84 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -50,6 +50,7 @@ #include "util/BootReason.h" #include "util/BuildInfo.h" #include "util/FontFamilyLabel.h" +#include "util/ReaderStatusBarJson.h" #include "util/StringUtils.h" namespace { @@ -117,6 +118,9 @@ uint8_t enumDisplayIndexForWeb(const SettingInfo& setting, uint8_t rawValue) { } bool isWebSettingAvailable(const SettingInfo& setting) { + if (setting.category == StrId::STR_STATUS_BARS || setting.nameId == StrId::STR_HIDE_CLOCK) { + return false; + } if (setting.nameId == StrId::STR_SIDE_BUTTON_CHORD && !deviceSupportsSideButtonChord(gpio)) { return false; } @@ -158,6 +162,7 @@ bool isWebSettingAvailable(const SettingInfo& setting) { if (!halClock.isAvailable()) { switch (setting.nameId) { case StrId::STR_HIDE_CLOCK: + case StrId::STR_CLOCK_OUTSIDE_READER: case StrId::STR_AUTO_BACKUP_STATS: case StrId::STR_CLOCK_UTC_OFFSET: case StrId::STR_CLOCK_DST: @@ -479,6 +484,8 @@ void CrossPointWebServer::begin() { server->on("/settings", HTTP_GET, [this] { handleSettingsPage(); }); server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); }); server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); }); + server->on("/api/status-bars", HTTP_GET, [this] { handleGetStatusBars(); }); + server->on("/api/status-bars", HTTP_POST, [this] { handlePostStatusBars(); }); // Font management endpoints server->on("/fonts", HTTP_GET, [this] { handleFontsPage(); }); @@ -1661,6 +1668,95 @@ void CrossPointWebServer::handleSettingsPage() const { sendStaticContent(server.get(), SettingsPageHtml, sizeof(SettingsPageHtml), SettingsPageHtmlETag); } +void CrossPointWebServer::handleGetStatusBars() const { + JsonDocument doc; + writeReaderStatusBarJson(doc["top"].to(), SETTINGS.readerStatusBar(ReaderStatusBarPosition::Top)); + writeReaderStatusBarJson(doc["bottom"].to(), SETTINGS.readerStatusBar(ReaderStatusBarPosition::Bottom)); + doc["xtcMode"] = SETTINGS.xtcStatusBarMode; + doc["clockAvailable"] = halClock.isAvailable(); + + JsonObject labels = doc["labels"].to(); + labels["top"] = tr(STR_TOP_STATUS_BAR); + labels["bottom"] = tr(STR_BOTTOM_STATUS_BAR); + labels["left"] = tr(STR_STATUS_BAR_LEFT); + labels["center"] = tr(STR_CENTER); + labels["right"] = tr(STR_STATUS_BAR_RIGHT); + labels["percentageFormat"] = tr(STR_PERCENTAGE_FORMAT); + labels["progressBar"] = tr(STR_PROGRESS_BAR); + labels["thickness"] = tr(STR_PROGRESS_BAR_THICKNESS); + labels["xtcMode"] = tr(STR_XTC_STATUS_BAR); + labels["preview"] = tr(STR_PREVIEW); + + JsonArray options = doc["options"].to(); + const auto addOption = [&options](ReaderStatusBarItem item, const std::string& label) { + JsonObject option = options.add(); + option["value"] = static_cast(item); + option["label"] = label; + }; + addOption(ReaderStatusBarItem::Clock, tr(STR_STATUS_BAR_CLOCK)); + addOption(ReaderStatusBarItem::Battery, tr(STR_BATTERY)); + const auto combined = [](const char* first, const char* second) { return std::string(first) + " (" + second + ")"; }; + addOption(ReaderStatusBarItem::TimeLeftBook, combined(tr(STR_TIME_LEFT), tr(STR_BOOK)).c_str()); + addOption(ReaderStatusBarItem::TimeLeftChapter, combined(tr(STR_TIME_LEFT), tr(STR_CHAPTER)).c_str()); + addOption(ReaderStatusBarItem::ChapterPageCount, tr(STR_CHAPTER_PAGE_COUNT)); + addOption(ReaderStatusBarItem::StablePageNumber, tr(STR_STABLE_PAGE_NUMBERS)); + addOption(ReaderStatusBarItem::BookProgressPercentage, tr(STR_BOOK_PROGRESS_PERCENTAGE)); + addOption(ReaderStatusBarItem::TitleBook, combined(tr(STR_TITLE), tr(STR_BOOK)).c_str()); + addOption(ReaderStatusBarItem::TitleChapter, combined(tr(STR_TITLE), tr(STR_CHAPTER)).c_str()); + addOption(ReaderStatusBarItem::Empty, tr(STR_STATUS_BAR_EMPTY)); + + const auto addLabels = [&doc](const char* name, std::initializer_list ids) { + JsonArray labels = doc[name].to(); + for (const StrId id : ids) labels.add(I18N.get(id)); + }; + addLabels("percentageFormats", {StrId::STR_PERCENTAGE_FORMAT_WHOLE, StrId::STR_PERCENTAGE_FORMAT_ONE_DECIMAL, + StrId::STR_PERCENTAGE_FORMAT_TWO_DECIMALS}); + addLabels("progressModes", {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}); + addLabels("thicknesses", + {StrId::STR_PROGRESS_BAR_THIN, StrId::STR_PROGRESS_BAR_MEDIUM, StrId::STR_PROGRESS_BAR_THICK}); + addLabels("xtcModes", {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP, StrId::STR_STATUS_BAR_BOTH}); + + String payload; + serializeJson(doc, payload); + server->send(200, "application/json", payload); +} + +void CrossPointWebServer::handlePostStatusBars() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing JSON body"); + return; + } + JsonDocument doc; + if (deserializeJson(doc, server->arg("plain"))) { + server->send(400, "text/plain", "Invalid JSON"); + return; + } + ReaderStatusBarsPayload bars; + if (!CrossPointSettings::parseReaderStatusBars(doc.as(), bars)) { + server->send(400, "text/plain", "Invalid status bar configuration"); + return; + } + { + std::lock_guard lock(SETTINGS.getMutex()); + const auto& previousTop = SETTINGS.topReaderStatusBar; + if (previousTop.slots != bars.top.slots || previousTop.percentageFormat != bars.top.percentageFormat || + previousTop.progressBar != bars.top.progressBar || + previousTop.progressBarThickness != bars.top.progressBarThickness || + SETTINGS.xtcStatusBarMode != bars.xtcMode) { + SETTINGS.legacyXtcTopUsesBottom = 0; + } + SETTINGS.topReaderStatusBar = bars.top; + SETTINGS.bottomReaderStatusBar = bars.bottom; + SETTINGS.xtcStatusBarMode = bars.xtcMode; + } + if (!SETTINGS.saveToFile()) { + LOG_ERR("WEB", "Failed to save status bar configuration"); + server->send(500, "text/plain", "Failed to save status bars"); + return; + } + server->send(200, "text/plain", "Status bars saved"); +} + void CrossPointWebServer::handleGetSettings() const { // The device settings UI needs an owned, mutable copy of the settings list. // The web API only reads it, so iterate the static base list directly rather diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 55df1299013..caa8735b0a9 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -130,6 +130,8 @@ class CrossPointWebServer { void handleSettingsPage() const; void handleGetSettings() const; void handlePostSettings(); + void handleGetStatusBars() const; + void handlePostStatusBars(); // Font management handlers void handleFontsPage() const; diff --git a/src/network/UsbSerialFileTransfer.cpp b/src/network/UsbSerialFileTransfer.cpp index 430dcfe6e65..8e6168342c5 100644 --- a/src/network/UsbSerialFileTransfer.cpp +++ b/src/network/UsbSerialFileTransfer.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -276,6 +277,7 @@ bool removeRecursive(const char* path, size_t depth = 0) { if (!file.isDirectory()) { file.close(); + library::invalidateLibraryIndex(); const bool removed = Storage.remove(path); if (removed) clearCachesForPath(path); return removed; @@ -567,6 +569,7 @@ void handleWrite() { return; } + library::invalidateLibraryIndex(); sdFontSystem.markRegistryDirtyForPath(path); if (Storage.exists(path)) { Storage.remove(path); @@ -633,6 +636,7 @@ void handleRename() { } if (Storage.rename(src, dst)) { + library::invalidateLibraryIndex(); clearCachesForPath(src); clearCachesForPath(dst); ImageFolderIndex::invalidateForPath(src); diff --git a/src/simulator/SimulatorSmokeTest.cpp b/src/simulator/SimulatorSmokeTest.cpp index c06594ee9d3..c56e06b7a6f 100644 --- a/src/simulator/SimulatorSmokeTest.cpp +++ b/src/simulator/SimulatorSmokeTest.cpp @@ -2,6 +2,7 @@ #include "SimulatorSmokeTest.h" +#include #include #include #include @@ -25,10 +26,12 @@ #include "CrossPointSettings.h" #include "DeviceCapabilities.h" #include "MappedInputManager.h" +#include "RecentBooksStore.h" #include "SdCardFontSystem.h" #include "SettingsList.h" #include "activities/ActivityManager.h" -#include "activities/reader/EpubReaderMenuActivity.h" +#include "activities/home/RecentBookProgress.h" +#include "activities/reader/EpubReaderDrawerActivity.h" #include "activities/reader/ReaderFontLoading.h" #include "activities/reader/ReaderOptionsActivity.h" #include "activities/reader/ReaderUtils.h" @@ -109,6 +112,8 @@ class SimulatorSmokeTest { const char* activeStepName = nullptr; std::vector inputScript; size_t scriptIndex = 0; + unsigned libraryRefreshPass = 0; + uint16_t libraryBaselineBooks = 0; SmokeStep inputCompletionStep = SmokeStep::Done; static bool enabled() { return std::getenv("CROSSINK_SIMULATOR_SMOKE_TEST") != nullptr; } @@ -438,6 +443,42 @@ class SimulatorSmokeTest { CrossPointSettings::CHORD_TOGGLE_FRONTLIGHT) != chordSetting->enumRawValues.end()) { fail("Toggle Frontlight is still offered without a frontlight"); } + + const auto hasLibrary = [](const SettingInfo& setting, const ShortcutOptionCatalog catalog) { + const auto raw = shortcutRawValue(catalog, CrossPointSettings::LIBRARY); + const auto choice = std::find(setting.enumRawValues.begin(), setting.enumRawValues.end(), raw); + return choice != setting.enumRawValues.end() && + setting.enumValues[static_cast(choice - setting.enumRawValues.begin())] == StrId::STR_LIBRARY; + }; + const auto verifyLibraryChoice = [&](const char* key, const ShortcutOptionCatalog catalog) { + const auto setting = std::find_if(allSettings.begin(), allSettings.end(), + [key](const SettingInfo& candidate) { return settingKeyIs(candidate, key); }); + if (setting == allSettings.end() || !hasLibrary(*setting, catalog)) { + fail("Library shortcut is missing or mislabeled in %s", key); + } + }; + verifyLibraryChoice("shortPwrBtn", ShortcutOptionCatalog::PowerButton); + verifyLibraryChoice("longPwrBtn", ShortcutOptionCatalog::PowerButton); + verifyLibraryChoice("powerChordAction", ShortcutOptionCatalog::ButtonChord); + verifyLibraryChoice("longPressMenuAction", ShortcutOptionCatalog::LongPress); + verifyLibraryChoice("longPressBackAction", ShortcutOptionCatalog::LongPress); + if (gpio.hasHomeKey()) { + verifyLibraryChoice("homeButtonTapAction", ShortcutOptionCatalog::HomeButton); + verifyLibraryChoice("homeButtonDoubleTapAction", ShortcutOptionCatalog::HomeButton); + verifyLibraryChoice("homeButtonLongPressAction", ShortcutOptionCatalog::HomeButton); + } + if (hasSideButtonChord) { + const auto side = + std::find_if(sideButtonSettings.begin(), sideButtonSettings.end(), + [](const SettingInfo& setting) { return settingKeyIs(setting, "sideButtonChordAction"); }); + if (side == sideButtonSettings.end() || !hasLibrary(*side, ShortcutOptionCatalog::ButtonChord)) { + fail("Library shortcut is missing from the filtered Up + Down choices"); + } + } + if (!QuickActions::isQuickActionSlotActionAvailable(CrossPointSettings::LIBRARY) || + QuickActions::actionLabel(CrossPointSettings::LIBRARY) != StrId::STR_LIBRARY) { + fail("Library is missing from Quick Actions choices"); + } } [[noreturn]] static void fail(const char* message) { @@ -488,6 +529,26 @@ class SimulatorSmokeTest { LOG_INF("SMOKE", "Loading popup preserves backdrop in all orientations"); } + static void verifyCachedHomeProgressMigration() { + const RecentBook book{"/books/legacy-home-smoke.epub", "Legacy Home smoke", {}, {}}; + const std::string legacy = "/.crosspoint/epub_" + std::to_string(std::hash{}(book.path)); + const std::string current = Epub::cachePathForFilePath(book.path, "/.crosspoint"); + if (legacy == current || Storage.exists(book.path.c_str())) fail("Invalid legacy Home fixture"); + if (!Storage.mkdir(legacy.c_str())) fail("Cannot create legacy Home cache"); + RecentBookProgress::saveCachedEpubPercent(legacy, 42.5f); + // There is deliberately no EPUB or book.bin. This must migrate and read the + // tiny saved percentage without attempting to open, parse or index a book. + if (RecentBookProgress::loadCachedEpubPercent(book) != 42.5f || Storage.exists(legacy.c_str()) || + !Storage.exists(current.c_str()) || BookMetadataCache::exists(current)) + fail("Home did not recover legacy cached progress without opening the EPUB"); + if (!Storage.mkdir(legacy.c_str())) fail("Cannot recreate stale legacy Home cache"); + RecentBookProgress::saveCachedEpubPercent(legacy, 90.0f); + if (RecentBookProgress::loadCachedEpubPercent(book) != 42.5f || !Storage.exists(legacy.c_str())) + fail("Stale legacy progress replaced the current Home cache"); + if (!Storage.removeDir(legacy.c_str()) || !Storage.removeDir(current.c_str())) fail("Cannot remove Home fixtures"); + LOG_INF("SMOKE", "Legacy Home progress migration without EPUB loading passed"); + } + void tickImpl() { mappedInputManager.simulatorClearInputFrame(); @@ -504,6 +565,7 @@ class SimulatorSmokeTest { case SmokeStep::Start: LOG_INF("SMOKE", "Starting simulator smoke test"); verifyLoadingPopupBackdrop(); + verifyCachedHomeProgressMigration(); if (!CrossPointSettings::verifySleepTimeoutMigrationContract()) { fail("Sleep timeout migration contract failed"); } @@ -745,8 +807,33 @@ class SimulatorSmokeTest { const bool hasFixture = std::getenv("CROSSINK_SIMULATOR_SMOKE_BOOK") != nullptr; const bool readable = shelf.open(library::libraryIndexPath()); const bool populated = readable && (!hasFixture || shelf.bookCount() > 0); + const uint16_t books = shelf.bookCount(); shelf.close(); if (!populated) fail("Library did not publish a readable populated index"); + constexpr char REFRESH_FIXTURE[] = "/books/library-refresh-smoke.txt"; + if (library::libraryIndexNeedsRefresh()) fail("Successful Library scan stayed dirty"); + if (libraryRefreshPass == 0) { + libraryBaselineBooks = books; + // Deliberately bypass invalidation to prove that a normal return visit + // reuses the index instead of walking the card again. + if (!Storage.writeFile(REFRESH_FIXTURE, "Library refresh smoke fixture")) + fail("Cannot create Library fixture"); + } else if (libraryRefreshPass == 1) { + if (books != libraryBaselineBooks) fail("Library rescanned an unchanged session"); + library::invalidateLibraryIndex(); + } else if (libraryRefreshPass == 2) { + if (books != libraryBaselineBooks + 1) fail("Library missed an invalidated addition"); + if (!Storage.remove(REFRESH_FIXTURE)) fail("Cannot delete Library fixture"); + library::invalidateLibraryIndex(); + } else if (books != libraryBaselineBooks) { + fail("Library missed an invalidated deletion"); + } + if (libraryRefreshPass++ < 3) { + activityManager.goToLibrary(); + queueStep("Library cache reuse and invalidation", SmokeStep::Library); + break; + } + LOG_INF("SMOKE", "Library reuse, addition and deletion refresh passed"); if (mappedInputManager.hasHomeKey()) { renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise); } @@ -762,9 +849,9 @@ class SimulatorSmokeTest { break; case SmokeStep::ReaderOptions: - activityManager.replaceActivity( - std::make_unique(renderer, mappedInputManager, "Smoke Test", 1, 1, 0, - SETTINGS.orientation, false, false, false, false, false, false)); + activityManager.replaceActivity(std::make_unique( + renderer, mappedInputManager, std::shared_ptr{}, nullptr, 0.0f, 0, 0, false, false, false, false, + false, false, false, false, 0, 0, 5, false)); queueStep("Reader Menu", SmokeStep::ReaderMenu); break; @@ -914,7 +1001,7 @@ class SimulatorSmokeTest { inputScript.push_back(touchMove(width / 2, height * 3 / 4)); inputScript.push_back(touchRelease(width / 2, height * 3 / 4)); inputScript.push_back(render("Reader Menu opened for TTF Native transition", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(touchDown(width / (static_cast(READER_DRAWER_TAB_COUNT) * 2), tabY)); inputScript.push_back(touchRelease(width / (static_cast(READER_DRAWER_TAB_COUNT) * 2), tabY)); addTap(MappedInputManager::Button::Confirm); @@ -922,7 +1009,7 @@ class SimulatorSmokeTest { addTap(MappedInputManager::Button::Down); addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("TTF Rendering opened in reader drawer", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("TTF Hinting choices opened in reader drawer", 3)); addTap(MappedInputManager::Button::Down); @@ -932,7 +1019,7 @@ class SimulatorSmokeTest { inputScript.push_back(touchDown(width / 2, tabY)); inputScript.push_back(touchRelease(width / 2, tabY)); inputScript.push_back(render("Reader Menu tab changed after TTF Native selection", 5)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); addTap(MappedInputManager::Button::Back); inputScript.push_back(render("Reader restored after TTF Native selection", 10)); inputScript.push_back(assertActivity("EpubReader")); @@ -983,9 +1070,8 @@ class SimulatorSmokeTest { inputScript.push_back(touchRelease(20, 8)); inputScript.push_back(render("Frontlight Panel remains open after in-drawer swipe up", 4)); inputScript.push_back(assertActivity("FrontlightPanel")); - // X4 Pro's portrait frontlight sheet ends just below mid-screen; this - // point lands in its centered 29 px handle band. - inputScript.push_back(touchDown(width / 2, height * 21 / 40)); + // The book-progress row puts the portrait sheet's handle near 58% height. + inputScript.push_back(touchDown(width / 2, height * 23 / 40)); inputScript.push_back(touchMove(width / 2, 8)); inputScript.push_back(touchRelease(width / 2, 8)); inputScript.push_back(render("Reader restored after Frontlight Panel handle drag up", 4)); @@ -997,8 +1083,8 @@ class SimulatorSmokeTest { inputScript.push_back(assertActivity("FrontlightPanel")); // The fourth action-bar slot opens Global Settings through the real // FrontlightPanelActivity callback path. - inputScript.push_back(touchDown(width * 7 / 10, height * 15 / 32)); - inputScript.push_back(touchRelease(width * 7 / 10, height * 15 / 32)); + inputScript.push_back(touchDown(width * 7 / 10, height * 21 / 40)); + inputScript.push_back(touchRelease(width * 7 / 10, height * 21 / 40)); inputScript.push_back(render("Global Settings opened from Frontlight Panel", 4)); inputScript.push_back(assertActivity("Settings")); inputScript.push_back(touchDown(width / 2, height * 3 / 4)); @@ -1016,8 +1102,8 @@ class SimulatorSmokeTest { inputScript.push_back(touchRelease(width / 2, height / 4)); inputScript.push_back(render("Frontlight Panel reopened after Global Settings", 4)); inputScript.push_back(assertActivity("FrontlightPanel")); - inputScript.push_back(touchDown(width * 3 / 10, height * 3 / 8)); - inputScript.push_back(touchRelease(width * 3 / 10, height * 3 / 8)); + inputScript.push_back(touchDown(width * 3 / 10, height * 21 / 40)); + inputScript.push_back(touchRelease(width * 3 / 10, height * 21 / 40)); inputScript.push_back(render("Sync dialog opened from Frontlight Panel", 4)); inputScript.push_back(assertActivity("FrontlightPanel")); inputScript.push_back(touchDown(width / 2, height - 60)); @@ -1026,25 +1112,25 @@ class SimulatorSmokeTest { inputScript.push_back(assertActivity("EpubReader")); inputScript.push_back(homeLongPress()); inputScript.push_back(render("Reader Menu opened from simulated Home key hold", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(touchDown(width / 2, height / 2 + 24)); inputScript.push_back(touchRelease(width / 2, height / 2 + 24)); inputScript.push_back(render("Reader Font opened from touch reader menu", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(homeTap()); inputScript.push_back(render("Reader Menu root restored by simulated Home key tap", 8)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(homeTap()); inputScript.push_back(render("Reader restored by simulated Home key tap at drawer root", 8)); inputScript.push_back(assertActivity("EpubReader")); inputScript.push_back(homeLongPress()); inputScript.push_back(render("Reader Menu reopened from simulated Home key hold", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(touchDown(width / 2, height * 3 / 4)); inputScript.push_back(touchMove(width / 2, height - 8)); inputScript.push_back(touchRelease(width / 2, height - 8)); inputScript.push_back(render("Reader Menu remains open after in-drawer swipe down", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(touchDown(width / 2, height / 2 - 14)); inputScript.push_back(touchMove(width / 2, height - 8)); inputScript.push_back(touchRelease(width / 2, height - 8)); @@ -1053,7 +1139,7 @@ class SimulatorSmokeTest { inputScript.push_back(disableReaderTouch()); inputScript.push_back(homeLongPress()); inputScript.push_back(render("Reader Menu opened from Home key hold with touch disabled", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(touchDown(width / 2, height / 4)); inputScript.push_back(touchRelease(width / 2, height / 4)); inputScript.push_back(render("Reader restored after Home key menu with touch disabled", 4)); @@ -1090,7 +1176,7 @@ class SimulatorSmokeTest { inputScript.push_back(touchRelease(width / 2, height * 3 / 4)); } inputScript.push_back(render("Reader Menu opened from touch gesture", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); // Touch every bottom-drawer tab slot, then dismiss from its handle. for (int tab = 0; tab < static_cast(READER_DRAWER_TAB_COUNT); ++tab) { @@ -1098,7 +1184,7 @@ class SimulatorSmokeTest { inputScript.push_back(touchDown(tabX, tabY)); inputScript.push_back(touchRelease(tabX, tabY)); inputScript.push_back(render("Touch Reader Menu tab", 3)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); } const int moreTabX = width / 2; @@ -1111,14 +1197,14 @@ class SimulatorSmokeTest { inputScript.push_back(touchDown(width / 2, drawerTop + rootRowStep + rootRowCenterOffset)); inputScript.push_back(touchRelease(width / 2, drawerTop + rootRowStep + rootRowCenterOffset)); inputScript.push_back(render("Touch Reader Go to Percent pane", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(touchDown(20, drawerTop + 26)); inputScript.push_back(touchRelease(20, drawerTop + 26)); inputScript.push_back(render("Touch Reader More tab restored", 3)); inputScript.push_back(touchDown(width / 2, drawerTop + rootRowStep * 2 + rootRowCenterOffset)); inputScript.push_back(touchRelease(width / 2, drawerTop + rootRowStep * 2 + rootRowCenterOffset)); inputScript.push_back(render("Touch Reader Auto Page Turn pane", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(touchDown(20, drawerTop + 26)); inputScript.push_back(touchRelease(20, drawerTop + 26)); inputScript.push_back(render("Touch Reader More tab restored", 3)); @@ -1126,7 +1212,7 @@ class SimulatorSmokeTest { inputScript.push_back(touchMove(width / 2, height - 8)); inputScript.push_back(touchRelease(width / 2, height - 8)); inputScript.push_back(render("Reader Menu remains open after in-drawer swipe down", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(touchDown(width / 2, drawerTop - 14)); inputScript.push_back(touchRelease(width / 2, drawerTop - 14)); inputScript.push_back(render("Reader restored after drawer handle tap", 4)); @@ -1136,12 +1222,12 @@ class SimulatorSmokeTest { inputScript.push_back(touchMove(width / 2, height * 3 / 4)); inputScript.push_back(touchRelease(width / 2, height * 3 / 4)); inputScript.push_back(render("Reader Menu reopened for bottom-edge Home gesture", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(touchDown(width / 2, height * 3 / 4)); inputScript.push_back(touchMove(width / 2, height / 2 + 8)); inputScript.push_back(touchRelease(width / 2, height / 2 + 8)); inputScript.push_back(render("Reader Menu remains open after interior swipe up", 4)); - inputScript.push_back(assertActivity("EpubReaderTouchMenu")); + inputScript.push_back(assertActivity("EpubReaderDrawer")); inputScript.push_back(touchDown(width / 2, height - 8)); inputScript.push_back(touchMove(width / 2, height * 3 / 4)); inputScript.push_back(touchRelease(width / 2, height * 3 / 4)); @@ -1159,19 +1245,77 @@ class SimulatorSmokeTest { inputScript.push_back(render("Reader Menu opened from EPUB", 4)); addTap(MappedInputManager::Button::Down); - inputScript.push_back(render("Reader Menu Reader Options selection", 3)); + inputScript.push_back(render("Reader Menu first row focused", 3)); + + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("Reader Font opened from Reader Menu", 4)); + + addTap(MappedInputManager::Button::Down); + inputScript.push_back(render("Font Size selected", 3)); addTap(MappedInputManager::Button::Confirm); - inputScript.push_back(render("Reader Options opened from Reader Menu", 4)); + inputScript.push_back(render("Font Size choices opened", 3)); +#if CROSSINK_APP_READER_SAMPLE_PREVIEW addTap(MappedInputManager::Button::Down); - inputScript.push_back(render("Reader Options after navigation", 3)); + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("C3 font size paragraph preview", 4)); +#endif + addTap(MappedInputManager::Button::Back); + inputScript.push_back(render("Reader Font after closing Font Size", 4)); + +#if CROSSINK_APP_READER_SAMPLE_PREVIEW addTap(MappedInputManager::Button::Confirm); - inputScript.push_back(render("Reader Options after toggle", 3)); + inputScript.push_back(render("C3 font family picker", 4)); + addTap(MappedInputManager::Button::Down); + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("C3 font family paragraph preview", 4)); + addTap(MappedInputManager::Button::Back); + inputScript.push_back(render("C3 reader font restored", 3)); +#endif addTap(MappedInputManager::Button::Back); - inputScript.push_back(render("Reader Menu after closing Reader Options", 4)); + inputScript.push_back(render("Reader Menu tab focus restored", 4)); + +#if CROSSINK_APP_READER_SAMPLE_PREVIEW + addTap(MappedInputManager::Button::Down); + addTap(MappedInputManager::Button::Down); + addTap(MappedInputManager::Button::Down); + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("C3 spacing paragraph preview", 4)); + addTap(MappedInputManager::Button::Confirm); + addTap(MappedInputManager::Button::Down); + inputScript.push_back(render("C3 line spacing adjusted", 4)); + addTap(MappedInputManager::Button::Back); + addTap(MappedInputManager::Button::Down); + addTap(MappedInputManager::Button::Confirm); + addTap(MappedInputManager::Button::Down); + inputScript.push_back(render("C3 word spacing adjusted", 4)); + addTap(MappedInputManager::Button::Back); + addTap(MappedInputManager::Button::Back); + inputScript.push_back(render("C3 font tab restored", 3)); +#endif + + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("Reader Menu advanced to next tab", 4)); + +#if CROSSINK_APP_READER_SAMPLE_PREVIEW + addTap(MappedInputManager::Button::Down); + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("C3 margin paragraph preview", 4)); + addTap(MappedInputManager::Button::Confirm); + addTap(MappedInputManager::Button::Down); + inputScript.push_back(render("C3 vertical margin adjusted", 4)); + addTap(MappedInputManager::Button::Back); + addTap(MappedInputManager::Button::Down); + addTap(MappedInputManager::Button::Confirm); + addTap(MappedInputManager::Button::Down); + inputScript.push_back(render("C3 horizontal margin adjusted", 4)); + addTap(MappedInputManager::Button::Back); + addTap(MappedInputManager::Button::Back); + inputScript.push_back(render("C3 layout tab restored", 3)); +#endif addTap(MappedInputManager::Button::Back); inputScript.push_back(render("Reader after closing Reader Menu", 4)); diff --git a/src/util/BookCacheUtils.cpp b/src/util/BookCacheUtils.cpp index 39605f7b98a..748acb257e7 100644 --- a/src/util/BookCacheUtils.cpp +++ b/src/util/BookCacheUtils.cpp @@ -218,6 +218,106 @@ bool restorePreservedFiles(const std::string& cachePath, const std::vector pendingStats; + pendingStats.reserve(MAX_STATS_FILES_TO_PRESERVE); + char entryName[128]; + for (FsFile entry = parent.openNextFile(); entry; entry = parent.openNextFile()) { + const bool isDirectory = entry.isDirectory(); + const size_t nameLen = entry.getName(entryName, sizeof(entryName)); + entry.close(); + if (isDirectory || nameLen >= sizeof(entryName) || nameLen <= prefix.size() || + strncmp(entryName, prefix.c_str(), prefix.size()) != 0 || !isStatsFileName(entryName + prefix.size())) + continue; + if (pendingStats.size() == MAX_STATS_FILES_TO_PRESERVE) { + parent.close(); + LOG_ERR("BookCache", "Too many interrupted stats files beside: %s", cachePath.c_str()); + return false; + } + pendingStats.emplace_back(entryName + prefix.size()); + } + const bool iterationFailed = FsHelpers::directoryIterationFailed(parent); + parent.close(); + if (iterationFailed) { + LOG_ERR("BookCache", "Failed to inspect preserved stats beside: %s", cachePath.c_str()); + return false; + } + if (std::any_of(pendingStats.begin(), pendingStats.end(), [&](const std::string& name) { + return !recoverPreservedTemp(cachePath, name, std::string(statsTmpPrefix) + name); + })) { + return false; + } + return removeStatsRecoveryMarker(cachePath, statsTmpPrefix); +} + bool preserveUserStateFiles(const std::string& cachePath, const std::vector& files, bool* movedFiles) { bool ok = true; @@ -228,14 +328,18 @@ bool preserveUserStateFiles(const std::string& cachePath, const std::vector preservedFiles; if (!resolvePreservedFiles(cachePath, fixedPreservedFiles, fixedPreservedCount, includeStatsFiles, statsTmpPrefix, preservedFiles)) { return false; } + const bool hasStats = includeStatsFiles && preservedFiles.size() > fixedPreservedCount; + if (hasStats && !createStatsRecoveryMarker(cachePath, statsTmpPrefix)) return false; bool movedFiles[MAX_PRESERVED_CACHE_FILES + MAX_STATS_FILES_TO_PRESERVE] = {}; const bool preserveOk = preserveUserStateFiles(cachePath, preservedFiles, movedFiles); if (!preserveOk) { - if (!restorePreservedFiles(cachePath, preservedFiles, movedFiles)) { + const bool restored = restorePreservedFiles(cachePath, preservedFiles, movedFiles); + if (!restored) { LOG_ERR("BookCache", "Failed to roll back preserved state after aborting cache clear: %s", cachePath.c_str()); } + if (hasStats && restored) removeStatsRecoveryMarker(cachePath, statsTmpPrefix); LOG_ERR("BookCache", "Aborted cache clear because preserved state could not be moved: %s", cachePath.c_str()); return false; } const bool clearOk = Storage.removeDir(cachePath.c_str()); const bool restoreOk = restorePreservedFiles(cachePath, preservedFiles, movedFiles); + const bool markerOk = !hasStats || !restoreOk || removeStatsRecoveryMarker(cachePath, statsTmpPrefix); if (!clearOk) { LOG_ERR("BookCache", "Failed to clear cache directory: %s", cachePath.c_str()); } - return clearOk && restoreOk; + return clearOk && restoreOk && markerOk; } } // namespace @@ -324,27 +434,33 @@ bool clearBookCachePreservingUserState(const std::string& path) { return false; } - std::vector resolvedFiles; const bool includeStatsFiles = FsHelpers::hasEpubExtension(path) || FsHelpers::hasXtcExtension(path); + if (!recoverInterruptedPreservation(cachePath, preservedFiles, preservedCount, includeStatsFiles, "upload_preserve_")) + return false; + + std::vector resolvedFiles; if (!resolvePreservedFiles(cachePath, preservedFiles, preservedCount, includeStatsFiles, "upload_preserve_", resolvedFiles)) { return false; } + const bool hasStats = includeStatsFiles && resolvedFiles.size() > preservedCount; + if (hasStats && !createStatsRecoveryMarker(cachePath, "upload_preserve_")) return false; bool movedFiles[MAX_PRESERVED_CACHE_FILES + MAX_STATS_FILES_TO_PRESERVE] = {}; const bool preserveOk = preserveUserStateFiles(cachePath, resolvedFiles, movedFiles); if (!preserveOk) { - if (!restorePreservedFiles(cachePath, resolvedFiles, movedFiles)) { + const bool restored = restorePreservedFiles(cachePath, resolvedFiles, movedFiles); + if (!restored) { LOG_ERR("BookCache", "Failed to roll back preserved state after aborting cache clear: %s", cachePath.c_str()); } + if (hasStats && restored) removeStatsRecoveryMarker(cachePath, "upload_preserve_"); LOG_ERR("BookCache", "Aborted cache clear because user state could not be preserved: %s", cachePath.c_str()); return false; } const bool clearOk = clearBookCacheForPath(path); const bool restoreOk = restorePreservedFiles(cachePath, resolvedFiles, movedFiles); - if (clearOk) { - } - return clearOk && restoreOk; + const bool markerOk = !hasStats || !restoreOk || removeStatsRecoveryMarker(cachePath, "upload_preserve_"); + return clearOk && restoreOk && markerOk; } bool clearBookCacheDirectoryPreservingStats(const std::string& cachePath) { diff --git a/src/util/BookMoveUtils.cpp b/src/util/BookMoveUtils.cpp index 0b7b80dd53f..6ece8211613 100644 --- a/src/util/BookMoveUtils.cpp +++ b/src/util/BookMoveUtils.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -184,6 +185,7 @@ RenameMigrationResult migrateRenamedBookState(const std::string& oldPath, const cacheMoved = true; } + library::invalidateLibraryIndex(); if (!Storage.rename(oldPath.c_str(), newPath.c_str())) { LOG_ERR("BookMove", "Failed to rename file: %s -> %s", oldPath.c_str(), newPath.c_str()); return recover(); @@ -211,6 +213,7 @@ RenameMigrationResult migrateRenamedBookState(const std::string& oldPath, const bool migrateMovedEpubState(const std::string& oldPath, const std::string& newPath, const std::string& oldCachePath, const std::string& title, const std::string& author, const bool keepInRecents) { + library::invalidateLibraryIndex(); bool ok = true; const std::string newCachePath = Epub::cachePathForFilePath(newPath, "/.crosspoint"); diff --git a/src/util/ButtonShortcutController.h b/src/util/ButtonShortcutController.h index 7585e70af15..1bc932885e2 100644 --- a/src/util/ButtonShortcutController.h +++ b/src/util/ButtonShortcutController.h @@ -39,6 +39,7 @@ class ButtonShortcutController { ToggleTouchscreen = 28, PreviousPage = 29, NearbyPositionSync = 30, + Library = 31, }; enum class Event : uint8_t { None, QuickLockChanged, Screenshot, PageTurn, ConfiguredAction, TouchscreenEscapeHatch }; diff --git a/src/util/ReaderBookProgress.h b/src/util/ReaderBookProgress.h new file mode 100644 index 00000000000..b461af53638 --- /dev/null +++ b/src/util/ReaderBookProgress.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include +#include +#include +#include + +// The reader menu and the frontlight panel show the same compact progress line. +// A zero page count means that the current format has no chapter page count. +inline void formatReaderBookProgress(char* buffer, size_t size, uint32_t chapterPage, uint32_t chapterPageCount, + bool chapterPageCountEstimated, int bookPercent) { + if (size == 0) return; + const int percent = std::clamp(bookPercent, 0, 100); + if (chapterPageCount > 0) { + std::snprintf(buffer, size, "%s%s%lu/%lu%s%s%d%%", tr(STR_CHAPTER_PREFIX), chapterPageCountEstimated ? "~" : "", + static_cast(std::min(chapterPage, chapterPageCount)), + static_cast(chapterPageCount), tr(STR_PAGES_SEPARATOR), tr(STR_BOOK_PREFIX), percent); + } else { + std::snprintf(buffer, size, "%s%d%%", tr(STR_BOOK_PREFIX), percent); + } +} diff --git a/src/util/ReaderStatusBarConfig.h b/src/util/ReaderStatusBarConfig.h new file mode 100644 index 00000000000..0f642483e04 --- /dev/null +++ b/src/util/ReaderStatusBarConfig.h @@ -0,0 +1,189 @@ +#pragma once + +#include +#include +#include + +enum class ReaderStatusBarPosition : uint8_t { Top, Bottom }; + +constexpr ReaderStatusBarPosition xtcStatusBarConfigPosition(const ReaderStatusBarPosition displayed, + const bool legacyTopUsesBottom) { + return displayed == ReaderStatusBarPosition::Top && legacyTopUsesBottom ? ReaderStatusBarPosition::Bottom : displayed; +} + +// The stored values are deliberately independent of the order shown in the picker. +enum class ReaderStatusBarItem : uint8_t { + Empty = 0, + Clock, + Battery, + TimeLeftBook, + TimeLeftChapter, + ChapterPageCount, + StablePageNumber, + BookProgressPercentage, + TitleBook, + TitleChapter, + Count, +}; + +constexpr bool validReaderStatusBarItemValue(const int value, const bool clockAvailable) { + return value >= 0 && value < static_cast(ReaderStatusBarItem::Count) && + (value != static_cast(ReaderStatusBarItem::Clock) || clockAvailable); +} + +constexpr bool validReaderStatusBarChoice(const int value, const int optionCount) { + return value >= 0 && value < optionCount; +} + +struct ReaderStatusBarConfig { + static constexpr int TOP_TEXT_INSET = 2; + static constexpr int BOOKMARK_WIDTH = 9; + static constexpr int BOOKMARK_HEIGHT = 14; + static constexpr unsigned SLOT_COUNT = 7; + static constexpr unsigned LEFT_FIRST = 0; + static constexpr unsigned LEFT_SECOND = 1; + static constexpr unsigned LEFT_THIRD = 2; + static constexpr unsigned CENTER = 3; + static constexpr unsigned RIGHT_FIRST = 4; + static constexpr unsigned RIGHT_SECOND = 5; + static constexpr unsigned RIGHT_THIRD = 6; + + std::array slots{}; + uint8_t percentageFormat = 0; + uint8_t progressBar = 2; + uint8_t progressBarThickness = 1; + + constexpr bool contains(ReaderStatusBarItem item) const { + // cppcheck-suppress useStlAlgorithm ; plain constexpr loop over three slots per side + for (const auto slot : slots) { + if (slot == item) return true; + } + return false; + } + + constexpr bool hasTextItems(bool clockAvailable) const { + // cppcheck-suppress useStlAlgorithm ; plain constexpr loop over three slots per side + for (const auto slot : slots) { + if (slot != ReaderStatusBarItem::Empty && (slot != ReaderStatusBarItem::Clock || clockAvailable)) return true; + } + return false; + } +}; + +struct ReaderStatusBarsPayload { + ReaderStatusBarConfig top; + ReaderStatusBarConfig bottom; + uint8_t xtcMode = 0; +}; + +constexpr int readerStatusBarTotalHeight(const ReaderStatusBarPosition position, const bool hasText, + const int progressSpace, const int textLaneHeight) { + if (!hasText && progressSpace > 0 && position == ReaderStatusBarPosition::Bottom) { + return std::max(progressSpace, ReaderStatusBarConfig::BOOKMARK_HEIGHT); + } + return (hasText + ? textLaneHeight + (position == ReaderStatusBarPosition::Top ? ReaderStatusBarConfig::TOP_TEXT_INSET : 0) + : 0) + + progressSpace; +} + +struct LegacyReaderStatusBarSettings { + bool chapterPageCount = false; + bool stablePageNumbers = false; + bool bookProgressPercentage = false; + uint8_t titleMode = 2; + uint8_t timeLeftMode = 0; + bool battery = false; + uint8_t percentageFormat = 0; + uint8_t progressBar = 2; + uint8_t progressBarThickness = 1; +}; + +constexpr ReaderStatusBarConfig migrateBottomStatusBar(const LegacyReaderStatusBarSettings& old) { + ReaderStatusBarConfig result; + result.percentageFormat = old.percentageFormat; + result.progressBar = old.progressBar; + result.progressBarThickness = old.progressBarThickness; + unsigned left = ReaderStatusBarConfig::LEFT_FIRST; + unsigned right = ReaderStatusBarConfig::RIGHT_FIRST; + if (old.battery) result.slots[left++] = ReaderStatusBarItem::Battery; + if (old.timeLeftMode == 1) result.slots[left++] = ReaderStatusBarItem::TimeLeftChapter; + if (old.timeLeftMode == 2) result.slots[left++] = ReaderStatusBarItem::TimeLeftBook; + if (old.titleMode == 0) result.slots[ReaderStatusBarConfig::CENTER] = ReaderStatusBarItem::TitleBook; + if (old.titleMode == 1) result.slots[ReaderStatusBarConfig::CENTER] = ReaderStatusBarItem::TitleChapter; + if (old.chapterPageCount) result.slots[right++] = ReaderStatusBarItem::ChapterPageCount; + if (old.stablePageNumbers) result.slots[right++] = ReaderStatusBarItem::StablePageNumber; + if (old.bookProgressPercentage) result.slots[right++] = ReaderStatusBarItem::BookProgressPercentage; + return result; +} + +struct ReaderStatusBarGeometry { + std::array x{}; + int centerLeft = 0; + int centerRight = 0; +}; + +// Keep short edge items at their natural width. When the edge clusters would +// overlap, trim the longest items until both clusters fit the available row. +inline void fitReaderStatusBarSideWidths(std::array& widths, + const int available, const int bookmarkReserve = 0, const int gap = 8) { + const auto visible = [](const int width) { return width > 0; }; + const int leftCount = + static_cast(std::count_if(widths.begin() + ReaderStatusBarConfig::LEFT_FIRST, + widths.begin() + ReaderStatusBarConfig::LEFT_THIRD + 1, visible)); + const int rightCount = + static_cast(std::count_if(widths.begin() + ReaderStatusBarConfig::RIGHT_FIRST, + widths.begin() + ReaderStatusBarConfig::RIGHT_THIRD + 1, visible)); + if (leftCount + rightCount == 0) return; + const int gaps = + std::max(0, leftCount - 1) + std::max(0, rightCount - 1) + (bookmarkReserve > 0 && leftCount > 0 ? 1 : 0); + const int budget = std::max(0, available - bookmarkReserve - gaps * gap); + int low = 0; + int high = budget; + while (low < high) { + const int cap = low + (high - low + 1) / 2; + int used = 0; + for (unsigned i = 0; i < ReaderStatusBarConfig::SLOT_COUNT; ++i) { + if (i == ReaderStatusBarConfig::CENTER) continue; + used += std::min(widths[i], cap); + } + if (used <= budget) + low = cap; + else + high = cap - 1; + } + for (unsigned i = 0; i < ReaderStatusBarConfig::SLOT_COUNT; ++i) { + if (i != ReaderStatusBarConfig::CENTER) widths[i] = std::min(widths[i], low); + } +} + +inline ReaderStatusBarGeometry layoutReaderStatusBarItems( + const int leftEdge, const int rightEdge, const std::array& widths, + const int bookmarkReserve = 0, const int gap = 8) { + ReaderStatusBarGeometry result; + int leftX = leftEdge + bookmarkReserve; + bool hasLeft = bookmarkReserve > 0; + for (unsigned i = ReaderStatusBarConfig::LEFT_FIRST; i <= ReaderStatusBarConfig::LEFT_THIRD; ++i) { + if (widths[i] <= 0) continue; + if (hasLeft) leftX += gap; + result.x[i] = leftX; + leftX += widths[i]; + hasLeft = true; + } + + int rightWidth = 0; + for (unsigned i = ReaderStatusBarConfig::RIGHT_FIRST; i <= ReaderStatusBarConfig::RIGHT_THIRD; ++i) { + if (widths[i] <= 0) continue; + if (rightWidth > 0) rightWidth += gap; + rightWidth += widths[i]; + } + int rightX = rightEdge - rightWidth; + for (unsigned i = ReaderStatusBarConfig::RIGHT_FIRST; i <= ReaderStatusBarConfig::RIGHT_THIRD; ++i) { + if (widths[i] <= 0) continue; + result.x[i] = rightX; + rightX += widths[i] + gap; + } + result.centerLeft = std::clamp(leftX + (hasLeft ? gap : 0), leftEdge, rightEdge); + result.centerRight = std::clamp(rightEdge - rightWidth - (rightWidth > 0 ? gap : 0), result.centerLeft, rightEdge); + return result; +} diff --git a/src/util/ReaderStatusBarJson.h b/src/util/ReaderStatusBarJson.h new file mode 100644 index 00000000000..15006906945 --- /dev/null +++ b/src/util/ReaderStatusBarJson.h @@ -0,0 +1,85 @@ +#pragma once + +#include + +#include "ReaderStatusBarConfig.h" + +inline void writeReaderStatusBarJson(JsonObject target, const ReaderStatusBarConfig& config) { + JsonArray slots = target["slots"].to(); + for (const auto item : config.slots) slots.add(static_cast(item)); + target["percentageFormat"] = config.percentageFormat; + target["progressBar"] = config.progressBar; + target["thickness"] = config.progressBarThickness; +} + +inline bool readReaderStatusBarJson(const JsonVariantConst source, ReaderStatusBarConfig& config, + const bool clockAvailable, const int percentageFormatCount, + const int progressModeCount, const int thicknessCount) { + const JsonArrayConst slots = source["slots"].as(); + if (slots.size() != ReaderStatusBarConfig::SLOT_COUNT) return false; + ReaderStatusBarConfig parsed; + for (unsigned i = 0; i < ReaderStatusBarConfig::SLOT_COUNT; ++i) { + const int item = slots[i].as(); + if (!slots[i].is() || !validReaderStatusBarItemValue(item, clockAvailable)) return false; + parsed.slots[i] = static_cast(item); + } + const int percentageFormat = source["percentageFormat"].as(); + const int progressMode = source["progressBar"].as(); + const int thickness = source["thickness"].as(); + if (!source["percentageFormat"].is() || !validReaderStatusBarChoice(percentageFormat, percentageFormatCount) || + !source["progressBar"].is() || !validReaderStatusBarChoice(progressMode, progressModeCount) || + !source["thickness"].is() || !validReaderStatusBarChoice(thickness, thicknessCount)) { + return false; + } + parsed.percentageFormat = percentageFormat; + parsed.progressBar = progressMode; + parsed.progressBarThickness = thickness; + config = parsed; + return true; +} + +inline bool readReaderStatusBarsPayload(const JsonVariantConst source, ReaderStatusBarsPayload& payload, + const bool clockAvailable, const int percentageFormatCount, + const int progressModeCount, const int thicknessCount, const int xtcModeCount) { + ReaderStatusBarsPayload parsed; + const int xtcMode = source["xtcMode"].as(); + if (!readReaderStatusBarJson(source["top"], parsed.top, clockAvailable, percentageFormatCount, progressModeCount, + thicknessCount) || + !readReaderStatusBarJson(source["bottom"], parsed.bottom, clockAvailable, percentageFormatCount, + progressModeCount, thicknessCount) || + !source["xtcMode"].is() || !validReaderStatusBarChoice(xtcMode, xtcModeCount)) { + return false; + } + parsed.xtcMode = static_cast(xtcMode); + payload = parsed; + return true; +} + +// Repairs a damaged saved bar one field at a time, retaining valid choices. +// Returns true when the caller must resave the corrected document. +inline bool repairReaderStatusBarJson(const JsonVariantConst source, ReaderStatusBarConfig& config, + const bool clockAvailable, const int percentageFormatCount, + const int progressModeCount, const int thicknessCount) { + if (readReaderStatusBarJson(source, config, clockAvailable, percentageFormatCount, progressModeCount, + thicknessCount)) { + return false; + } + const JsonArrayConst slots = source["slots"].as(); + if (slots.size() != ReaderStatusBarConfig::SLOT_COUNT) return true; + ReaderStatusBarConfig repaired; + for (unsigned i = 0; i < ReaderStatusBarConfig::SLOT_COUNT; ++i) { + const int item = slots[i].as(); + if (slots[i].is() && validReaderStatusBarItemValue(item, clockAvailable)) { + repaired.slots[i] = static_cast(item); + } + } + const auto choice = [](const JsonVariantConst value, const int count, const uint8_t fallback) { + const int raw = value.as(); + return value.is() && validReaderStatusBarChoice(raw, count) ? static_cast(raw) : fallback; + }; + repaired.percentageFormat = choice(source["percentageFormat"], percentageFormatCount, repaired.percentageFormat); + repaired.progressBar = choice(source["progressBar"], progressModeCount, repaired.progressBar); + repaired.progressBarThickness = choice(source["thickness"], thicknessCount, repaired.progressBarThickness); + config = repaired; + return true; +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 3fe02aebb5c..7a504f0ea61 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -65,6 +65,7 @@ add_subdirectory(pinch_font_resize) add_subdirectory(font_catalog_index) add_subdirectory(reader_font_sizes) add_subdirectory(reader_status_bar_tap_target) +add_subdirectory(reader_status_bar_config) add_subdirectory(two_finger_swipe) add_subdirectory(edge_slide) add_subdirectory(option_popup) @@ -104,3 +105,5 @@ add_subdirectory(library_builder) add_subdirectory(library_recent_order) add_subdirectory(scalable_fonts) + +add_subdirectory(glyph_bitmap) diff --git a/test/glyph_bitmap/CMakeLists.txt b/test/glyph_bitmap/CMakeLists.txt new file mode 100644 index 00000000000..e36daeecfb8 --- /dev/null +++ b/test/glyph_bitmap/CMakeLists.txt @@ -0,0 +1,3 @@ +add_executable(GlyphBitmapTest GlyphBitmapTest.cpp) +target_link_libraries(GlyphBitmapTest PRIVATE crosspoint_test_common GTest::gtest_main) +gtest_discover_tests(GlyphBitmapTest) diff --git a/test/glyph_bitmap/GlyphBitmapTest.cpp b/test/glyph_bitmap/GlyphBitmapTest.cpp new file mode 100644 index 00000000000..6b6ace26183 --- /dev/null +++ b/test/glyph_bitmap/GlyphBitmapTest.cpp @@ -0,0 +1,126 @@ +#include + +#include +#include +#include + +#include "lib/GfxRenderer/GlyphBitmap.h" + +namespace { +constexpr int PANEL_WIDTH = 40; +constexpr int PANEL_HEIGHT = 32; +constexpr int STRIDE = PANEL_WIDTH / 8; + +using glyphBitmap::Plane; + +// Logical placements used by the renderer: unrotated text, and side-button +// labels rotated 90 degrees clockwise (glyph x runs up, glyph y runs right). +constexpr std::array, 2> AXES = {{{1, 0, 0, 1}, {0, -1, 1, 0}}}; + +std::pair physical(int orientation, int x, int y) { + switch (orientation) { + case 0: + return {y, PANEL_HEIGHT - 1 - x}; + case 1: + return {PANEL_WIDTH - 1 - x, PANEL_HEIGHT - 1 - y}; + case 2: + return {PANEL_WIDTH - 1 - y, x}; + default: + return {x, y}; + } +} + +// Logical rectangle the renderer clips to; the glyph-local clip is derived from it. +struct Rect { + int left; + int top; + int right; + int bottom; +}; + +void compare(int orientation, int rotation, bool twoBit, Plane plane, bool state, int width, int height, int x, int y, + int originY, int rows, Rect clip) { + SCOPED_TRACE(::testing::Message() << orientation << ',' << rotation << ',' << twoBit << ',' << static_cast(plane) + << ',' << state << " size=" << width << 'x' << height << " at=" << x << ',' << y + << " band=" << originY << ',' << rows); + const auto [dxX, dxY, dyX, dyY] = AXES[rotation]; + const glyphBitmap::Frame logical{x, y, dxX, dxY, dyX, dyY}; + std::vector bitmap((width * height * (twoBit ? 2 : 1) + 7) / 8); + for (size_t i = 0; i < bitmap.size(); ++i) bitmap[i] = static_cast(i * 73 + 0x1b); + // Nonuniform background and guards verify transparent pixels and all off-band bytes. + std::vector expected(rows * STRIDE + 32); + for (size_t i = 0; i < expected.size(); ++i) expected[i] = static_cast(i * 53 + 0xa5); + auto actual = expected; + for (int gy = 0; gy < height; ++gy) { + for (int gx = 0; gx < width; ++gx) { + const int lx = x + gx * dxX + gy * dyX; + const int ly = y + gx * dxY + gy * dyY; + if (lx < clip.left || ly < clip.top || lx >= clip.right || ly >= clip.bottom) continue; + const auto [px, py] = physical(orientation, lx, ly); + if (px < 0 || px >= PANEL_WIDTH || py < originY || py >= originY + rows) continue; + const int source = gy * width + gx; + bool draw = false; + bool black = state; + if (twoBit) { + const int value = 3 - ((bitmap[source / 4] >> ((3 - source % 4) * 2)) & 3); + if (plane == Plane::BW) draw = value < 3; + if (plane == Plane::GrayLSB) draw = value == 1; + if (plane == Plane::GrayMSB) draw = value == 1 || value == 2; + if (plane != Plane::BW) black = false; + } else { + draw = (bitmap[source / 8] >> (7 - source % 8)) & 1; + } + if (!draw) continue; + auto& byte = expected[16 + (py - originY) * STRIDE + px / 8]; + if (black) + byte &= static_cast(~(0x80 >> (px % 8))); + else + byte |= 0x80 >> (px % 8); + } + } + + glyphBitmap::Clip local{0, 0, width, height}; + glyphBitmap::clipToRect(logical, clip.left, clip.top, clip.right, clip.bottom, local); + + const auto [px, py] = physical(orientation, x, y); + const auto [xx, xy] = physical(orientation, x + dxX, y + dxY); + const auto [yx, yy] = physical(orientation, x + dyX, y + dyY); + const glyphBitmap::Target target{ + actual.data() + 16, PANEL_WIDTH, STRIDE, originY, rows, {px, py, xx - px, xy - py, yx - px, yy - py}}; + glyphBitmap::draw(bitmap.data(), width, height, twoBit, plane, state, target, local); + EXPECT_EQ(expected, actual); +} +} // namespace + +TEST(GlyphBitmap, MatchesPerPixelReferenceAcrossOrientationsRotationsPlanesAndClipping) { + for (int orientation = 0; orientation < 4; ++orientation) { + for (int rotation = 0; rotation < 2; ++rotation) { + for (bool twoBit : {false, true}) { + for (Plane plane : {Plane::BW, Plane::GrayLSB, Plane::GrayMSB}) { + for (bool state : {false, true}) { + for (int width : {1, 3, 7, 16, 31}) { + for (const auto [x, y] : {std::pair{-5, -3}, std::pair{0, 0}, std::pair{9, 11}, std::pair{29, 31}}) { + for (const auto [origin, rows] : + {std::pair{0, 32}, std::pair{0, 7}, std::pair{7, 11}, std::pair{28, 4}}) { + compare(orientation, rotation, twoBit, plane, state, width, 13, x, y, origin, rows, + {-20, -20, 60, 60}); + compare(orientation, rotation, twoBit, plane, state, width, 13, x, y, origin, rows, {2, 3, 18, 20}); + } + } + } + } + } + } + } + } +} + +TEST(GlyphBitmap, EmptyAndFullyClippedGlyphsDoNotWrite) { + for (int orientation = 0; orientation < 4; ++orientation) { + for (int rotation = 0; rotation < 2; ++rotation) { + compare(orientation, rotation, true, Plane::BW, true, 0, 0, 0, 0, 0, 32, {0, 0, 40, 32}); + compare(orientation, rotation, true, Plane::BW, true, 11, 9, 100, 100, 0, 32, {0, 0, 40, 32}); + compare(orientation, rotation, true, Plane::BW, true, 11, 9, 0, 0, 0, 32, {12, 10, 15, 20}); + } + } +} diff --git a/test/library_builder/BookCacheUtilsTest.cpp b/test/library_builder/BookCacheUtilsTest.cpp new file mode 100644 index 00000000000..3a6212ceb68 --- /dev/null +++ b/test/library_builder/BookCacheUtilsTest.cpp @@ -0,0 +1,65 @@ +#include + +#include + +#include "../../src/util/BookCacheUtils.h" +#include "Epub.h" +#include "HalStorage.h" + +namespace { + +class BookCacheUtilsTest : public ::testing::Test { + protected: + std::string cachePath; + + void SetUp() override { + fake::reset(); + cachePath = Epub("/a.epub", "/.crosspoint").getCachePath(); + fake::add(cachePath + "/progress.bin", "progress"); + fake::add(cachePath + "/stats_v5.bin", "stats"); + fake::add(cachePath + "/reader_settings.bin", "settings"); + fake::add(cachePath + "/book.bin", "derived"); + } + + void expectUserStateIntact() { + const auto expectFile = [](const std::string& path, const std::string& expected) { + ASSERT_TRUE(Storage.exists(path.c_str())) << path; + const auto& bytes = fake::files.at(path)->bytes; + EXPECT_EQ(std::string(bytes.begin(), bytes.end()), expected); + }; + expectFile(cachePath + "/progress.bin", "progress"); + expectFile(cachePath + "/stats_v5.bin", "stats"); + expectFile(cachePath + "/reader_settings.bin", "settings"); + } +}; + +TEST_F(BookCacheUtilsTest, ClearsDerivedFilesWhileKeepingReadingState) { + ASSERT_TRUE(clearBookCachePreservingUserState("/a.epub")); + expectUserStateIntact(); + EXPECT_FALSE(Storage.exists((cachePath + "/book.bin").c_str())); + EXPECT_EQ(fake::directoryEntriesByPath[cachePath], 0u); +} + +TEST_F(BookCacheUtilsTest, RetryRecoversStatsAfterFailedRestore) { + fake::failRenameToPath = cachePath + "/stats_v5.bin"; + ASSERT_FALSE(clearBookCachePreservingUserState("/a.epub")); + ASSERT_TRUE(Storage.exists((cachePath + ".upload_preserve_stats_v5.bin").c_str())); + ASSERT_TRUE(Storage.exists((cachePath + ".upload_preserve_stats_pending").c_str())); + + ASSERT_TRUE(clearBookCachePreservingUserState("/a.epub")); + expectUserStateIntact(); + EXPECT_FALSE(Storage.exists((cachePath + ".upload_preserve_stats_v5.bin").c_str())); + EXPECT_FALSE(Storage.exists((cachePath + ".upload_preserve_stats_pending").c_str())); +} + +TEST_F(BookCacheUtilsTest, RetryRecoversProgressAfterFailedRestore) { + fake::failRenameToPath = cachePath + "/progress.bin"; + ASSERT_FALSE(clearBookCachePreservingUserState("/a.epub")); + ASSERT_TRUE(Storage.exists((cachePath + ".upload_preserve_progress.bin").c_str())); + + ASSERT_TRUE(clearBookCachePreservingUserState("/a.epub")); + expectUserStateIntact(); + EXPECT_FALSE(Storage.exists((cachePath + ".upload_preserve_progress.bin").c_str())); +} + +} // namespace diff --git a/test/library_builder/CMakeLists.txt b/test/library_builder/CMakeLists.txt index 66c13080705..f30413c65b7 100644 --- a/test/library_builder/CMakeLists.txt +++ b/test/library_builder/CMakeLists.txt @@ -14,3 +14,13 @@ target_include_directories(LibraryBuilderTest PRIVATE ) target_link_libraries(LibraryBuilderTest PRIVATE crosspoint_test_common GTest::gtest_main) gtest_discover_tests(LibraryBuilderTest) + +add_executable(BookCacheUtilsTest + BookCacheUtilsTest.cpp + ${REPO_ROOT}/src/util/BookCacheUtils.cpp +) +target_include_directories(BookCacheUtilsTest PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/stubs +) +target_link_libraries(BookCacheUtilsTest PRIVATE crosspoint_test_common GTest::gtest_main) +gtest_discover_tests(BookCacheUtilsTest) diff --git a/test/library_builder/LibraryBuilderTest.cpp b/test/library_builder/LibraryBuilderTest.cpp index 744367b14b9..dad933b309c 100644 --- a/test/library_builder/LibraryBuilderTest.cpp +++ b/test/library_builder/LibraryBuilderTest.cpp @@ -15,6 +15,14 @@ using namespace library; +std::vector preservedCacheClears; +bool preserveCacheState = true; + +bool clearBookCachePreservingUserState(const std::string& path) { + preservedCacheClears.push_back(path); + return preserveCacheState; +} + namespace { constexpr char INDEX[] = "/.crosspoint/library.idx"; @@ -84,9 +92,12 @@ class LibraryBuilderTest : public ::testing::Test { void SetUp() override { fake::reset(); + invalidateLibraryIndex(); bookMetadata.clear(); cachedBookMetadata.clear(); metadataCacheUse.clear(); + preservedCacheClears.clear(); + preserveCacheState = true; fake::add("/a.epub"); fake::add("/b.epub"); } @@ -110,6 +121,31 @@ TEST_F(LibraryBuilderTest, UnchangedRebuildReusesMetadataAndDoesNotReplaceIndex) EXPECT_EQ(fake::files[INDEX]->bytes, old); } +TEST_F(LibraryBuilderTest, MissingModificationDateClearsDerivedCacheThroughStatePreservingPath) { + fake::files["/a.epub"]->time = 0; + initial(); + preservedCacheClears.clear(); + metadataCacheUse.clear(); + + ASSERT_TRUE(buildLibraryIndex("/", stats, true)); + + EXPECT_EQ(preservedCacheClears, std::vector{"/a.epub"}); + ASSERT_EQ(metadataCacheUse.size(), 1u); + EXPECT_FALSE(metadataCacheUse.front()); +} + +TEST_F(LibraryBuilderTest, ChangedEpubAbortsIndexRefreshIfReadingStateCannotBePreserved) { + initial(); + fake::files["/a.epub"]->time++; + preserveCacheState = false; + fake::parses = 0; + + EXPECT_FALSE(buildLibraryIndex("/", stats, true)); + EXPECT_EQ(preservedCacheClears, std::vector{"/a.epub"}); + EXPECT_EQ(fake::parses, 0u); + EXPECT_TRUE(Storage.exists(INDEX)); +} + TEST_F(LibraryBuilderTest, FolderHeavyUnchangedReconciliationIoScalesLinearly) { const auto measure = [this](const unsigned count) { fake::reset(); @@ -882,6 +918,7 @@ TEST_F(LibraryBuilderTest, CreationSortAllocationFailureRetriesOnNextScan) { ASSERT_TRUE(index.open(INDEX)); foundArrivalFallback = (index.header().flags & CLIX_FLAG_ARRIVAL_DEGRADED) != 0; if (!foundArrivalFallback) continue; + EXPECT_TRUE(libraryIndexNeedsRefresh()); EXPECT_FALSE(stats.ranksDegraded); EXPECT_EQ(pathAt(index, SortOrder::RecentAsc, 0), "/a.txt"); index.close(); @@ -1111,3 +1148,35 @@ TEST_F(LibraryBuilderTest, PriorDedupDegradationForcesReplacement) { ASSERT_TRUE(index.open(INDEX)); EXPECT_EQ(index.header().flags & CLIX_FLAG_DEDUP_DEGRADED, 0); } + +TEST_F(LibraryBuilderTest, DirtyIndexClearsOnSuccessAndRetriesAfterFailure) { + EXPECT_TRUE(libraryIndexNeedsRefresh()); + initial(); + EXPECT_FALSE(libraryIndexNeedsRefresh()); + invalidateLibraryIndex(); + fake::failOpenPath = "/.crosspoint/library.idx"; + EXPECT_FALSE(buildLibraryIndex("/", stats, true)); + EXPECT_TRUE(libraryIndexNeedsRefresh()); + ASSERT_TRUE(buildLibraryIndex("/", stats, true)); + EXPECT_FALSE(libraryIndexNeedsRefresh()); +} + +TEST_F(LibraryBuilderTest, InvalidationDuringScanSurvivesSuccessfulPublish) { + fake::onService = &invalidateLibraryIndex; + initial(); + EXPECT_GT(fake::delays, 0u); + EXPECT_TRUE(libraryIndexNeedsRefresh()); + fake::onService = nullptr; + ASSERT_TRUE(buildLibraryIndex("/", stats, true)); + EXPECT_FALSE(libraryIndexNeedsRefresh()); +} + +TEST_F(LibraryBuilderTest, EmptyLibraryStillRecordsMetadataModeChanges) { + fake::files.erase("/a.epub"); + fake::files.erase("/b.epub"); + ASSERT_TRUE(buildLibraryIndex("/", stats, false)); + ASSERT_TRUE(buildLibraryIndex("/", stats, true)); + LibraryIndexFile index; + ASSERT_TRUE(index.open(INDEX)); + EXPECT_EQ(index.header().metadataEnabled, 1); +} diff --git a/test/library_builder/stubs/Arduino.h b/test/library_builder/stubs/Arduino.h index dda5328a225..d0547e97193 100644 --- a/test/library_builder/stubs/Arduino.h +++ b/test/library_builder/stubs/Arduino.h @@ -9,7 +9,10 @@ inline uint32_t millis() { return ++clock; } -inline void delay(unsigned) { fake::delays++; } +inline void delay(unsigned) { + fake::delays++; + if (fake::onService) fake::onService(); +} struct FakeEsp { uint32_t getFreeHeap() const { return 100000; } diff --git a/test/library_builder/stubs/Epub.h b/test/library_builder/stubs/Epub.h index a0f318be7be..eea7d2cab34 100644 --- a/test/library_builder/stubs/Epub.h +++ b/test/library_builder/stubs/Epub.h @@ -25,6 +25,12 @@ class Epub { public: Epub(const std::string& path, const char*) : path(path) {} + std::string getCachePath() const { return "/.crosspoint/cache_" + path.substr(1); } + bool clearCache() const { + const std::string cachePath = getCachePath(); + return !Storage.exists(cachePath.c_str()) || Storage.removeDir(cachePath.c_str()); + } + bool loadMetadata(std::string& title, std::string& author, const bool allowCachedMetadata = true, std::string* series = nullptr, std::string* genre = nullptr, std::string* seriesIndex = nullptr) { ++fake::parses; diff --git a/test/library_builder/stubs/FsHelpers.h b/test/library_builder/stubs/FsHelpers.h index 478fd7509c4..1b193cf54aa 100644 --- a/test/library_builder/stubs/FsHelpers.h +++ b/test/library_builder/stubs/FsHelpers.h @@ -7,6 +7,8 @@ namespace FsHelpers { inline bool checkFileExtension(const std::string& path, const char* extension) { return path.ends_with(extension); } inline bool hasEpubExtension(const std::string& path) { return checkFileExtension(path, ".epub"); } +inline bool hasXtcExtension(const std::string& path) { return checkFileExtension(path, ".xtc"); } +inline bool hasTxtExtension(const std::string& path) { return checkFileExtension(path, ".txt"); } inline bool directoryIterationFailed(const HalFile& directory) { return directory.allocationFailed() || directory.iterationFailed(); } diff --git a/test/library_builder/stubs/HalStorage.h b/test/library_builder/stubs/HalStorage.h index ca30362245a..4b41abc4867 100644 --- a/test/library_builder/stubs/HalStorage.h +++ b/test/library_builder/stubs/HalStorage.h @@ -20,6 +20,7 @@ inline std::map> files; inline int failRead = -1; inline int failWrite = -1; inline int failRename = -1; +inline std::string failRenameToPath; inline int failAlloc = -1; inline bool failDirectorySeek = false; inline std::string failDirectoryIterationPath; @@ -30,6 +31,7 @@ inline unsigned parses = 0; inline unsigned reads = 0; inline unsigned seeks = 0; inline unsigned delays = 0; +inline void (*onService)() = nullptr; inline std::map writesByPath; inline std::map directoryEntriesByPath; inline bool failureTriggered = false; @@ -51,6 +53,7 @@ inline void reset() { failRead = -1; failWrite = -1; failRename = -1; + failRenameToPath.clear(); failAlloc = -1; failDirectorySeek = false; failDirectoryIterationPath.clear(); @@ -61,6 +64,7 @@ inline void reset() { reads = 0; seeks = 0; delays = 0; + onService = nullptr; writesByPath.clear(); directoryEntriesByPath.clear(); failureTriggered = false; @@ -188,6 +192,7 @@ class HalFile { return size; } size_t write(const void* data, const size_t size) { return write(static_cast(data), size); } + bool sync() { return bool(node); } }; class HalStorage { @@ -230,7 +235,22 @@ class HalStorage { return openFileForWrite(module, path.c_str(), file); } bool remove(const char* path) { return fake::files.erase(path) != 0; } + bool removeDir(const char* path) { + if (!exists(path)) return false; + const std::string prefix = std::string(path) + "/"; + for (auto it = fake::files.begin(); it != fake::files.end();) { + if (it->first == path || it->first.starts_with(prefix)) + it = fake::files.erase(it); + else + ++it; + } + return true; + } bool rename(const char* from, const char* to) { + if (!fake::failRenameToPath.empty() && fake::failRenameToPath == to) { + fake::failRenameToPath.clear(); + return false; + } if (fake::fail(fake::failRename) || !exists(from) || exists(to)) return false; fake::files[to] = fake::files[from]; fake::files.erase(from); @@ -238,4 +258,6 @@ class HalStorage { } }; +using FsFile = HalFile; + #define Storage HalStorage::getInstance() diff --git a/test/library_builder/stubs/Txt.h b/test/library_builder/stubs/Txt.h new file mode 100644 index 00000000000..55a6152b222 --- /dev/null +++ b/test/library_builder/stubs/Txt.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "HalStorage.h" + +class Txt { + std::string path; + + public: + Txt(const std::string& path, const char*) : path(path) {} + std::string getCachePath() const { return "/.crosspoint/cache_" + path.substr(1); } + bool clearCache() const { + const std::string cachePath = getCachePath(); + return !Storage.exists(cachePath.c_str()) || Storage.removeDir(cachePath.c_str()); + } +}; diff --git a/test/library_builder/stubs/Xtc.h b/test/library_builder/stubs/Xtc.h new file mode 100644 index 00000000000..9d10cce1b5f --- /dev/null +++ b/test/library_builder/stubs/Xtc.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include "HalStorage.h" + +class Xtc { + std::string path; + + public: + Xtc(const std::string& path, const char*) : path(path) {} + std::string getCachePath() const { return "/.crosspoint/cache_" + path.substr(1); } + bool clearCache() const { + const std::string cachePath = getCachePath(); + return !Storage.exists(cachePath.c_str()) || Storage.removeDir(cachePath.c_str()); + } +}; diff --git a/test/memory_policy/SdFontBitmapStorageTest.cpp b/test/memory_policy/SdFontBitmapStorageTest.cpp index 72ead8f3a86..91336051708 100644 --- a/test/memory_policy/SdFontBitmapStorageTest.cpp +++ b/test/memory_policy/SdFontBitmapStorageTest.cpp @@ -44,6 +44,37 @@ std::vector fixture() { for (uint8_t i = 0; i < 32; i++) b.push_back(i); return b; } +// Four styles with deliberately distinct advances but usually identical coverage. +std::vector multiStyleFixture(bool supplementary, bool differentLastStyle = false) { + std::vector bytes(32 + 4 * 32, 0); + memcpy(bytes.data(), "CPFONT\0\0", 8); + put16(bytes, 8, CPFONT_VERSION); + bytes[12] = 4; + for (uint8_t style = 0; style < 4; ++style) { + const size_t toc = 32 + style * 32; + bytes[toc] = style; + put32(bytes, toc + 4, 2); + put32(bytes, toc + 8, 4); + bytes[toc + 12] = 16; + put16(bytes, toc + 13, 12); + put32(bytes, toc + 24, bytes.size()); + const uint32_t first = supplementary ? 0x10000 : 'A'; + const uint32_t offset = differentLastStyle && style == 3 ? 3 : 0; + append(bytes, EpdUnicodeInterval{first + offset, first + offset + 2, 0}); + append(bytes, EpdUnicodeInterval{supplementary ? 0x1FFFDu : 0xFFFDu, supplementary ? 0x1FFFDu : 0xFFFDu, 3}); + for (uint32_t i = 0; i < 4; ++i) { + EpdGlyph glyph{}; + glyph.width = 8; + glyph.height = 8; + glyph.advanceX = 128 + style * 16; + glyph.dataLength = 8; + glyph.dataOffset = i * 8; + append(bytes, glyph); + } + bytes.resize(bytes.size() + 32, style); + } + return bytes; +} } // namespace struct SdFontBitmapStorageTest : testing::Test { void SetUp() override { @@ -159,3 +190,47 @@ TEST_F(SdFontBitmapStorageTest, ExternalBitmapsStillHonorUnderuseAndInternalPres EXPECT_TRUE(fakeheap::live.empty()); EXPECT_EQ(font.getEpdFont()->data->bitmap, nullptr); } + +TEST_F(SdFontBitmapStorageTest, SharedCoveragePreservesStyleMetricsAndSkipsDuplicateReads) { + for (bool supplementary : {false, true}) { + Storage.put("shared.cpfont", multiStyleFixture(supplementary)); + Storage.put("different.cpfont", multiStyleFixture(supplementary, true)); + SdCardFont font; + ASSERT_TRUE(font.load("shared.cpfont")); + const size_t sharedReads = Storage.data("shared.cpfont").readBytes; + const uint32_t first = supplementary ? 0x10000 : 'A'; + for (uint8_t style = 0; style < 4; ++style) { + ASSERT_NE(font.getEpdFont(style), nullptr); + EXPECT_TRUE(font.getEpdFont(style)->hasCodepoint(first)); + EXPECT_FALSE(font.getEpdFont(style)->hasCodepoint(first + 3)); + uint16_t advance = 0; + ASSERT_TRUE(font.readAdvance(first, style, &advance)); + EXPECT_EQ(advance, 128 + style * 16); + } + font.releaseForLowMemory(); + // Reload tears down the owner plus three borrowers. The last style now has + // different coverage, so it must own a separate table even with equal counts. + ASSERT_TRUE(font.load("different.cpfont")); + EXPECT_EQ(Storage.data("different.cpfont").readBytes, sharedReads + 2 * sizeof(EpdUnicodeInterval)); + EXPECT_FALSE(font.getEpdFont(3)->hasCodepoint(first)); + EXPECT_TRUE(font.getEpdFont(3)->hasCodepoint(first + 3)); + EXPECT_TRUE(font.getEpdFont(0)->hasCodepoint(first)); + EXPECT_FALSE(font.getEpdFont(0)->hasCodepoint(first + 3)); + } +} + +TEST_F(SdFontBitmapStorageTest, FailureAfterSharedStylesCanUnloadAndRetry) { + auto broken = multiStyleFixture(false); + const size_t styleBytes = 2 * sizeof(EpdUnicodeInterval) + 4 * sizeof(EpdGlyph) + 32; + // The first two styles share successfully before the third fails validation. + put32(broken, 32 + 4 * 32 + 2 * styleBytes, 'Z'); + Storage.put("broken.cpfont", broken); + Storage.put("shared.cpfont", multiStyleFixture(false)); + SdCardFont font; + EXPECT_FALSE(font.load("broken.cpfont")); + EXPECT_EQ(Storage.openHandles("broken.cpfont"), 0); + ASSERT_TRUE(font.load("shared.cpfont")); + ASSERT_EQ(font.prewarm("ABC", 0x0F), 0); + font.clearCache(); + ASSERT_EQ(font.prewarm("A", 0x0F), 0); +} diff --git a/test/option_popup/OptionPopupTest.cpp b/test/option_popup/OptionPopupTest.cpp index 4430a83bf70..de1cd77147a 100644 --- a/test/option_popup/OptionPopupTest.cpp +++ b/test/option_popup/OptionPopupTest.cpp @@ -53,6 +53,29 @@ TEST(OptionPopup, ConsecutiveTouchSelectionsDoNotLoseSecondRelease) { EXPECT_FALSE(popup.isActive()); } +TEST(OptionPopup, MenuAndSortDecorationsDoNotLeakToNextPopup) { + GfxRenderer renderer; + OptionPopup popup; + const char* options[] = {"First", "Second"}; + + popup.show("Sort", options, 2, 0, [](const int) {}); + popup.setDividerAfterOption(0); + popup.render(renderer); + EXPECT_EQ(renderer.lineCount(), 1); + EXPECT_EQ(renderer.triangleCount(), 0); + + popup.show("Menu", options, 2, 0, [](const int) {}); + popup.setSelectionArrow(true); + popup.render(renderer); + EXPECT_EQ(renderer.lineCount(), 1); + EXPECT_EQ(renderer.triangleCount(), 1); + + popup.show("Other", options, 2, 0, [](const int) {}); + popup.render(renderer); + EXPECT_EQ(renderer.lineCount(), 1); + EXPECT_EQ(renderer.triangleCount(), 1); +} + TEST(OptionPopup, PowerConfirmSelectionSuppressesItsPowerRelease) { GfxRenderer renderer; HalGPIO gpio; diff --git a/test/option_popup/stubs/GfxRenderer.h b/test/option_popup/stubs/GfxRenderer.h index ade43ee5ab1..f7c89c5dea4 100644 --- a/test/option_popup/stubs/GfxRenderer.h +++ b/test/option_popup/stubs/GfxRenderer.h @@ -19,4 +19,12 @@ class GfxRenderer { return {text}; } void displayBuffer() const {} + void drawLine(int, int, int, int) const { ++drawnLines; } + void fillPolygon(const int*, const int*, int, bool) const { ++drawnTriangles; } + int lineCount() const { return drawnLines; } + int triangleCount() const { return drawnTriangles; } + + private: + mutable int drawnLines = 0; + mutable int drawnTriangles = 0; }; diff --git a/test/option_popup/stubs/components/themes/BaseTheme.h b/test/option_popup/stubs/components/themes/BaseTheme.h index eab532104c6..4e92b3db4a0 100644 --- a/test/option_popup/stubs/components/themes/BaseTheme.h +++ b/test/option_popup/stubs/components/themes/BaseTheme.h @@ -22,5 +22,6 @@ struct ThemeMetrics { int optionPopupSelectionVPadding = 0; int optionPopupTitleGap = 0; bool optionPopupOptionFontBold = false; + bool optionPopupSelectionLight = false; int optionPopupDialogSideMargin = 0; }; diff --git a/test/quick_lock/QuickLockStateTest.cpp b/test/quick_lock/QuickLockStateTest.cpp index c2044be36f7..8e777a6c9cd 100644 --- a/test/quick_lock/QuickLockStateTest.cpp +++ b/test/quick_lock/QuickLockStateTest.cpp @@ -128,7 +128,7 @@ TEST(ButtonShortcutController, EveryChordActionConsumesBothReleaseOrders) { Action::JoinNetwork, Action::CreateHotspot, Action::ToggleDarkMode, Action::Footnotes, Action::FileBrowser, Action::CreateClipping, Action::LookupWord, Action::ToggleHomeButton, Action::QuickActions, - Action::ToggleFrontlight, Action::ToggleTouchscreen, + Action::ToggleFrontlight, Action::ToggleTouchscreen, Action::Library, }; for (const auto action : actions) { @@ -148,6 +148,25 @@ TEST(ButtonShortcutController, EveryChordActionConsumesBothReleaseOrders) { } } +TEST(ButtonShortcutController, LibraryUpDownChordFiresOnceAndReleasesBothButtons) { + using Action = ButtonShortcutController::ChordAction; + using Event = ButtonShortcutController::Event; + + for (const bool releaseUpFirst : {false, true}) { + ButtonShortcutController controller; + const auto started = controller.updateUpDown(1U, true, true, Action::Library, false); + EXPECT_EQ(started.event, Event::ConfiguredAction); + EXPECT_EQ(started.action, Action::Library); + EXPECT_TRUE(started.consumeInput); + + const auto firstRelease = controller.updateUpDown(2U, !releaseUpFirst, releaseUpFirst, Action::Library, false); + EXPECT_EQ(firstRelease.event, Event::None); + EXPECT_TRUE(firstRelease.consumeInput); + EXPECT_TRUE(controller.updateUpDown(3U, false, false, Action::Library, false).consumeInput); + EXPECT_FALSE(controller.updateUpDown(4U, false, false, Action::Library, false).consumeInput); + } +} + TEST(ButtonShortcutController, ModalConsumesPowerUpChordThroughBothReleases) { ButtonShortcutController controller; using Action = ButtonShortcutController::ChordAction; diff --git a/test/reader_drawer_model/ReaderDrawerModelTest.cpp b/test/reader_drawer_model/ReaderDrawerModelTest.cpp index 46876e3a72a..a09a3a65e36 100644 --- a/test/reader_drawer_model/ReaderDrawerModelTest.cpp +++ b/test/reader_drawer_model/ReaderDrawerModelTest.cpp @@ -13,6 +13,50 @@ TEST(ReaderDrawerModel, TabOrderMatchesTouchDesign) { EXPECT_EQ(static_cast(ReaderDrawerTab::Settings), 4); } +TEST(ReaderDrawerModel, ButtonTabsWrapAtBothEnds) { + EXPECT_EQ(adjacentReaderDrawerTab(ReaderDrawerTab::Settings, true), ReaderDrawerTab::Font); + EXPECT_EQ(adjacentReaderDrawerTab(ReaderDrawerTab::Font, false), ReaderDrawerTab::Settings); + EXPECT_EQ(adjacentReaderDrawerTab(ReaderDrawerTab::More, true), ReaderDrawerTab::Location); +} + +TEST(ReaderDrawerModel, ButtonSliderFocusEditAndBackAreExplicit) { + ReaderButtonSliderState state; + EXPECT_EQ(readerButtonSliderInput(state, ReaderButtonSliderInput::Next), ReaderButtonSliderAction::None); + EXPECT_EQ(state.focus, 1); + EXPECT_EQ(readerButtonSliderInput(state, ReaderButtonSliderInput::Confirm), ReaderButtonSliderAction::None); + EXPECT_TRUE(state.editing); + EXPECT_EQ(readerButtonSliderInput(state, ReaderButtonSliderInput::Next), ReaderButtonSliderAction::Increase); + EXPECT_EQ(readerButtonSliderInput(state, ReaderButtonSliderInput::Previous), ReaderButtonSliderAction::Decrease); + EXPECT_EQ(readerButtonSliderInput(state, ReaderButtonSliderInput::Back), ReaderButtonSliderAction::None); + EXPECT_FALSE(state.editing); + EXPECT_EQ(readerButtonSliderInput(state, ReaderButtonSliderInput::Previous), ReaderButtonSliderAction::None); + EXPECT_EQ(state.focus, 0); + EXPECT_EQ(readerButtonSliderInput(state, ReaderButtonSliderInput::Back), ReaderButtonSliderAction::LeavePane); +} + +TEST(ReaderDrawerModel, FailedPreviewRestoresFontWithoutDiscardingOtherEdits) { + ReaderSettingsDraft draft; + draft.fontFamily = 2; + draft.readerFontPointSize = 25; + draft.sdFontFamilyName[0] = 'X'; + draft.orientation = 3; + draft.hyphenationEnabled = 1; + draft.screenMarginHorizontal = 45; + ReaderSettingsDraft lastGood; + lastGood.fontFamily = 1; + lastGood.readerFontPointSize = 18; + lastGood.orientation = 0; + lastGood.screenMarginHorizontal = 15; + + restoreReaderDraftFont(draft, lastGood); + EXPECT_EQ(draft.fontFamily, 1); + EXPECT_EQ(draft.readerFontPointSize, 18); + EXPECT_EQ(draft.sdFontFamilyName[0], '\0'); + EXPECT_EQ(draft.orientation, 3); + EXPECT_EQ(draft.hyphenationEnabled, 1); + EXPECT_EQ(draft.screenMarginHorizontal, 45); +} + TEST(ReaderDrawerModel, TapSlidersSnapArbitraryValuesToTheNearestFive) { EXPECT_EQ(snapSliderTapValue(82, 70, 200, 5), 80); // Line Spacing EXPECT_EQ(snapSliderTapValue(83, 70, 200, 5), 85); // Line Spacing @@ -115,6 +159,30 @@ TEST(ReaderDrawerModel, CatalogOrderAndConditionalRowsMatchTouchDesign) { EXPECT_EQ(settings.items[9], ReaderDrawerCatalogItem::ResetBookReaderSettings); } +TEST(ReaderDrawerModel, ButtonDevicesRestoreStatsAndTransfersInTheirTabs) { + ReaderDrawerAvailability available{}; + available.buttonDevice = true; + const ReaderDrawerCatalog catalog = makeReaderDrawerCatalog(available); + const auto& more = catalog[static_cast(ReaderDrawerTab::More)]; + EXPECT_EQ(more.count, 4); + EXPECT_EQ(more.items[3], ReaderDrawerCatalogItem::ReadingStats); + + const auto& location = catalog[static_cast(ReaderDrawerTab::Location)]; + EXPECT_EQ(location.count, 7); + EXPECT_EQ(location.items[2], ReaderDrawerCatalogItem::SyncProgress); + EXPECT_EQ(location.items[3], ReaderDrawerCatalogItem::NearbyPositionSync); + EXPECT_EQ(location.items[4], ReaderDrawerCatalogItem::SendNearbyBook); + EXPECT_EQ(location.items[5], ReaderDrawerCatalogItem::Screenshot); + EXPECT_EQ(location.items[6], ReaderDrawerCatalogItem::DisplayQr); + + available.hasBookmarks = true; + available.hasClippings = true; + const ReaderDrawerCatalog fullCatalog = makeReaderDrawerCatalog(available); + const auto& fullLocation = fullCatalog[static_cast(ReaderDrawerTab::Location)]; + EXPECT_EQ(fullLocation.count, 10); + EXPECT_EQ(fullLocation.items[5], ReaderDrawerCatalogItem::SyncProgress); +} + TEST(ReaderDrawerModel, ChangeMaskSeparatesPreviewRelayoutAndOrientation) { const auto mask = ReaderSettingsChangeMask::Preview | ReaderSettingsChangeMask::Relayout | ReaderSettingsChangeMask::Orientation; @@ -152,12 +220,6 @@ TEST(FrontlightPanelModel, TouchDrawerSupportsFrontlightOrReaderDetails) { EXPECT_FALSE(supportsFrontlightDrawer(false, false, true)); } -TEST(ReaderDrawerModel, ReopenRequiresExplicitTouchDrawerResult) { - EXPECT_TRUE(shouldReopenTouchReaderDrawer(true, true)); - EXPECT_FALSE(shouldReopenTouchReaderDrawer(false, true)); - EXPECT_FALSE(shouldReopenTouchReaderDrawer(true, false)); -} - TEST(ReaderDrawerModel, PercentStepsDoNotBecomeSettingsChanges) { EXPECT_TRUE(readerDrawerStepChangesSettings(ReaderDrawerPane::Spacing)); EXPECT_TRUE(readerDrawerStepChangesSettings(ReaderDrawerPane::Margins)); @@ -230,3 +292,19 @@ TEST(PendingOverlayResume, ConsumptionIsOneShot) { PendingOverlayResume second; EXPECT_FALSE(consumePendingOverlayResumeOnce(stored, second)); } + +TEST(ReaderDrawerModel, SamplePreviewCoversLiveTextSettingsOnly) { + using Pane = ReaderDrawerPane; + using Tab = ReaderDrawerTab; + using Row = ReaderDrawerCatalogItem; + EXPECT_TRUE(readerDrawerShowsSamplePreview(Pane::Root, Tab::Font, Row::FontSize)); + for (const auto pane : {Pane::ReaderFont, Pane::FontFamily, Pane::Spacing, Pane::Margins}) + EXPECT_TRUE(readerDrawerShowsSamplePreview(pane, Tab::Layout, Row::FontSize)); + for (const auto row : {Row::FontSize, Row::Alignment}) + EXPECT_TRUE(readerDrawerShowsSamplePreview(Pane::EnumOptions, Tab::Layout, row)); + for (const auto tab : {Tab::Layout, Tab::More, Tab::Location, Tab::Settings}) + EXPECT_FALSE(readerDrawerShowsSamplePreview(Pane::Root, tab, Row::FontSize)); + for (const auto row : {Row::Orientation, Row::DictionaryFontFamily, Row::DictionaryFontSize, Row::Images}) + EXPECT_FALSE(readerDrawerShowsSamplePreview(Pane::EnumOptions, Tab::Layout, row)); + EXPECT_FALSE(readerDrawerShowsSamplePreview(Pane::DictionaryFont, Tab::Font, Row::FontSize)); +} diff --git a/test/reader_status_bar_config/CMakeLists.txt b/test/reader_status_bar_config/CMakeLists.txt new file mode 100644 index 00000000000..9ce5f4a58c2 --- /dev/null +++ b/test/reader_status_bar_config/CMakeLists.txt @@ -0,0 +1,11 @@ +FetchContent_Declare( + arduinojson + GIT_REPOSITORY https://github.com/bblanchon/ArduinoJson.git + GIT_TAG v7.4.2 +) +FetchContent_MakeAvailable(arduinojson) + +add_executable(ReaderStatusBarConfigTest ReaderStatusBarConfigTest.cpp) +target_include_directories(ReaderStatusBarConfigTest PRIVATE ${REPO_ROOT}/src/util ${arduinojson_SOURCE_DIR}/src) +target_link_libraries(ReaderStatusBarConfigTest PRIVATE crosspoint_test_common GTest::gtest_main) +gtest_discover_tests(ReaderStatusBarConfigTest) diff --git a/test/reader_status_bar_config/ReaderStatusBarConfigTest.cpp b/test/reader_status_bar_config/ReaderStatusBarConfigTest.cpp new file mode 100644 index 00000000000..cf5ad9f4585 --- /dev/null +++ b/test/reader_status_bar_config/ReaderStatusBarConfigTest.cpp @@ -0,0 +1,198 @@ +#include + +#include "ReaderStatusBarConfig.h" +#include "ReaderStatusBarJson.h" + +TEST(ReaderStatusBarConfig, MigratesSixLegacyItemsIntoSevenSlots) { + const LegacyReaderStatusBarSettings old{true, true, true, 1, 2, true, 2, 0, 2}; + const auto bar = migrateBottomStatusBar(old); + EXPECT_EQ(bar.slots[0], ReaderStatusBarItem::Battery); + EXPECT_EQ(bar.slots[1], ReaderStatusBarItem::TimeLeftBook); + EXPECT_EQ(bar.slots[2], ReaderStatusBarItem::Empty); + EXPECT_EQ(bar.slots[3], ReaderStatusBarItem::TitleChapter); + EXPECT_EQ(bar.slots[4], ReaderStatusBarItem::ChapterPageCount); + EXPECT_EQ(bar.slots[5], ReaderStatusBarItem::StablePageNumber); + EXPECT_EQ(bar.slots[6], ReaderStatusBarItem::BookProgressPercentage); + EXPECT_EQ(bar.percentageFormat, 2); + EXPECT_EQ(bar.progressBar, 0); + EXPECT_EQ(bar.progressBarThickness, 2); +} + +TEST(ReaderStatusBarConfig, LegacyXtcTopInitiallyUsesMigratedBottomItems) { + const auto bottom = migrateBottomStatusBar({true, true, true, 1, 2, true, 2, 0, 2}); + ReaderStatusBarConfig top; + top.slots[ReaderStatusBarConfig::CENTER] = ReaderStatusBarItem::Clock; + const auto select = [&](const ReaderStatusBarPosition displayed, + const bool legacyTopUsesBottom) -> const ReaderStatusBarConfig& { + return xtcStatusBarConfigPosition(displayed, legacyTopUsesBottom) == ReaderStatusBarPosition::Top ? top : bottom; + }; + EXPECT_EQ(select(ReaderStatusBarPosition::Top, true).slots, bottom.slots); + EXPECT_EQ(select(ReaderStatusBarPosition::Top, false).slots, top.slots); + EXPECT_EQ(select(ReaderStatusBarPosition::Bottom, true).slots, bottom.slots); +} + +TEST(ReaderStatusBarConfig, EmptySlotsDoNotReserveSpaceAndDuplicatesStayIndependent) { + ReaderStatusBarConfig bar; + bar.slots = {ReaderStatusBarItem::Battery, ReaderStatusBarItem::Empty, ReaderStatusBarItem::Empty, + ReaderStatusBarItem::TitleBook, ReaderStatusBarItem::Empty, ReaderStatusBarItem::Battery, + ReaderStatusBarItem::Empty}; + EXPECT_TRUE(bar.contains(ReaderStatusBarItem::Battery)); + const std::array widths{16, 0, 0, 0, 0, 16, 0}; + const auto placement = layoutReaderStatusBarItems(10, 190, widths); + EXPECT_EQ(placement.x[0], 10); + EXPECT_EQ(placement.x[5], 174); + EXPECT_EQ(placement.centerLeft, 34); + EXPECT_EQ(placement.centerRight, 166); +} + +TEST(ReaderStatusBarConfig, LoneSideTitleUsesFreeRowWidth) { + std::array widths{500, 0, 0, 0, 0, 0, 0}; + fitReaderStatusBarSideWidths(widths, 700); + EXPECT_EQ(widths[0], 500); + const auto placement = layoutReaderStatusBarItems(10, 710, widths); + EXPECT_EQ(placement.x[0], 10); + EXPECT_EQ(placement.centerLeft, 518); + EXPECT_EQ(placement.centerRight, 710); +} + +TEST(ReaderStatusBarConfig, CrowdedSidesFitWithoutOverlap) { + std::array widths{200, 24, 24, 0, 200, 24, 24}; + fitReaderStatusBarSideWidths(widths, 400, 13); + const auto placement = layoutReaderStatusBarItems(10, 410, widths, 13); + EXPECT_GT(placement.x[2], placement.x[1]); + EXPECT_LE(placement.centerLeft, placement.centerRight); + EXPECT_EQ(placement.x[6] + widths[6], 410); + EXPECT_EQ(widths[1], 24); + EXPECT_EQ(widths[2], 24); + EXPECT_EQ(widths[5], 24); +} + +TEST(ReaderStatusBarConfig, ProgressOnlyBottomReservesBookmarkAndAutoTurnText) { + EXPECT_EQ(readerStatusBarTotalHeight(ReaderStatusBarPosition::Top, false, 7, 19), 7); + EXPECT_EQ(readerStatusBarTotalHeight(ReaderStatusBarPosition::Bottom, false, 7, 19), 14); + EXPECT_EQ(readerStatusBarTotalHeight(ReaderStatusBarPosition::Bottom, true, 7, 19), 26); +} + +TEST(ReaderStatusBarConfig, BookmarkStaysAtBottomLeftAndRightItemsAnchorToEdge) { + const std::array widths{20, 0, 0, 0, 15, 0, 10}; + const auto placement = layoutReaderStatusBarItems(10, 190, widths, 13); + EXPECT_EQ(placement.x[0], 31); + EXPECT_EQ(placement.x[4], 157); + EXPECT_EQ(placement.x[6], 180); + EXPECT_LE(placement.centerLeft, placement.centerRight); +} + +TEST(ReaderStatusBarConfig, RejectsInvalidSavedSlotsAndUnavailableClock) { + EXPECT_TRUE(validReaderStatusBarItemValue(static_cast(ReaderStatusBarItem::Clock), true)); + EXPECT_FALSE(validReaderStatusBarItemValue(static_cast(ReaderStatusBarItem::Clock), false)); + EXPECT_FALSE(validReaderStatusBarItemValue(-1, true)); + EXPECT_FALSE(validReaderStatusBarItemValue(static_cast(ReaderStatusBarItem::Count), true)); + EXPECT_TRUE(validReaderStatusBarChoice(2, 3)); + EXPECT_FALSE(validReaderStatusBarChoice(3, 3)); +} + +TEST(ReaderStatusBarConfig, WebPayloadRoundTripsBothBarsWithDuplicates) { + ReaderStatusBarConfig top; + top.slots = {ReaderStatusBarItem::Clock, + ReaderStatusBarItem::Battery, + ReaderStatusBarItem::TimeLeftBook, + ReaderStatusBarItem::TitleBook, + ReaderStatusBarItem::Battery, + ReaderStatusBarItem::Empty, + ReaderStatusBarItem::BookProgressPercentage}; + top.percentageFormat = 2; + top.progressBar = 0; + JsonDocument outgoing; + writeReaderStatusBarJson(outgoing["top"].to(), top); + writeReaderStatusBarJson(outgoing["bottom"].to(), migrateBottomStatusBar({})); + outgoing["xtcMode"] = 3; + + std::string saved; + serializeJson(outgoing, saved); + JsonDocument reloaded; + ASSERT_FALSE(deserializeJson(reloaded, saved)); + ReaderStatusBarsPayload loaded; + ASSERT_TRUE(readReaderStatusBarsPayload(reloaded.as(), loaded, true, 3, 3, 3, 4)); + EXPECT_EQ(loaded.top.slots, top.slots); + EXPECT_EQ(loaded.top.percentageFormat, 2); + EXPECT_EQ(loaded.top.progressBar, 0); + EXPECT_EQ(loaded.xtcMode, 3); +} + +TEST(ReaderStatusBarConfig, WebPayloadRequiresBothBarsAndValidXtcModeBeforeSaving) { + JsonDocument request; + writeReaderStatusBarJson(request["top"].to(), ReaderStatusBarConfig{}); + request["xtcMode"] = 2; + ReaderStatusBarsPayload parsed; + EXPECT_FALSE(readReaderStatusBarsPayload(request.as(), parsed, true, 3, 3, 3, 4)); + writeReaderStatusBarJson(request["bottom"].to(), ReaderStatusBarConfig{}); + EXPECT_TRUE(readReaderStatusBarsPayload(request.as(), parsed, true, 3, 3, 3, 4)); + request["xtcMode"] = 4; + EXPECT_FALSE(readReaderStatusBarsPayload(request.as(), parsed, true, 3, 3, 3, 4)); + EXPECT_EQ(parsed.xtcMode, 2); +} + +TEST(ReaderStatusBarConfig, WebPayloadRejectsInvalidOrIncompleteBars) { + JsonDocument payload; + auto bar = payload["top"].to(); + writeReaderStatusBarJson(bar, ReaderStatusBarConfig{}); + ReaderStatusBarConfig loaded; + EXPECT_TRUE(readReaderStatusBarJson(payload["top"], loaded, true, 3, 3, 3)); + bar["slots"][0] = 99; + EXPECT_FALSE(readReaderStatusBarJson(payload["top"], loaded, true, 3, 3, 3)); + bar["slots"][0] = 1; + EXPECT_FALSE(readReaderStatusBarJson(payload["top"], loaded, false, 3, 3, 3)); + bar["slots"][0] = 0; + bar["percentageFormat"] = 3; + EXPECT_FALSE(readReaderStatusBarJson(payload["top"], loaded, true, 3, 3, 3)); + bar["percentageFormat"] = 0; + bar["slots"].as().remove(6); + EXPECT_FALSE(readReaderStatusBarJson(payload["top"], loaded, true, 3, 3, 3)); +} + +TEST(ReaderStatusBarConfig, RepairsDamagedSavedValuesWithoutDroppingValidSlots) { + JsonDocument saved; + auto bar = saved["bottom"].to(); + ReaderStatusBarConfig original; + original.slots = {ReaderStatusBarItem::Battery, + ReaderStatusBarItem::Clock, + ReaderStatusBarItem::Empty, + ReaderStatusBarItem::TitleBook, + ReaderStatusBarItem::ChapterPageCount, + ReaderStatusBarItem::Empty, + ReaderStatusBarItem::Empty}; + writeReaderStatusBarJson(bar, original); + bar["slots"][4] = 99; + bar["thickness"] = 99; + + ReaderStatusBarConfig repaired; + EXPECT_TRUE(repairReaderStatusBarJson(saved["bottom"], repaired, false, 3, 3, 3)); + EXPECT_EQ(repaired.slots[0], ReaderStatusBarItem::Battery); + EXPECT_EQ(repaired.slots[1], ReaderStatusBarItem::Empty); + EXPECT_EQ(repaired.slots[3], ReaderStatusBarItem::TitleBook); + EXPECT_EQ(repaired.slots[4], ReaderStatusBarItem::Empty); + EXPECT_EQ(repaired.progressBarThickness, 1); +} + +TEST(ReaderStatusBarConfig, WrongLengthSavedBarKeepsCurrentDefault) { + JsonDocument saved; + JsonArray slots = saved["slots"].to(); + for (int i = 0; i < 6; ++i) slots.add(static_cast(ReaderStatusBarItem::Battery)); + ReaderStatusBarConfig config; + config.slots[ReaderStatusBarConfig::CENTER] = ReaderStatusBarItem::TitleChapter; + + EXPECT_TRUE(repairReaderStatusBarJson(saved.as(), config, true, 3, 3, 3)); + EXPECT_EQ(config.slots[ReaderStatusBarConfig::LEFT_THIRD], ReaderStatusBarItem::Empty); + EXPECT_EQ(config.slots[ReaderStatusBarConfig::CENTER], ReaderStatusBarItem::TitleChapter); +} + +TEST(ReaderStatusBarLayout, TopTextUsesHomeInsetAndOnlyEnabledProgressReservesSpace) { + constexpr int textLane = 19; + EXPECT_EQ(readerStatusBarTotalHeight(ReaderStatusBarPosition::Top, true, 0, textLane), 21); + EXPECT_EQ(readerStatusBarTotalHeight(ReaderStatusBarPosition::Top, true, 7, textLane), 28); + EXPECT_EQ(readerStatusBarTotalHeight(ReaderStatusBarPosition::Top, false, 0, textLane), 0); + EXPECT_EQ(readerStatusBarTotalHeight(ReaderStatusBarPosition::Top, false, 7, textLane), 7); + // Bottom text and progress-only bookmark reservations retain their existing bounds. + EXPECT_EQ(readerStatusBarTotalHeight(ReaderStatusBarPosition::Bottom, true, 0, textLane), 19); + EXPECT_EQ(readerStatusBarTotalHeight(ReaderStatusBarPosition::Bottom, false, 7, textLane), 14); +} diff --git a/test/reader_status_bar_tap_target/ReaderStatusBarTapTargetTest.cpp b/test/reader_status_bar_tap_target/ReaderStatusBarTapTargetTest.cpp index 35e670ea98b..41cba208495 100644 --- a/test/reader_status_bar_tap_target/ReaderStatusBarTapTargetTest.cpp +++ b/test/reader_status_bar_tap_target/ReaderStatusBarTapTargetTest.cpp @@ -29,3 +29,10 @@ TEST(ReaderStatusBarTapTarget, RejectsMissingOrInvalidStatusBarRegions) { EXPECT_FALSE(ReaderStatusBarTapTarget::containsTop(8, 480, -1, 19)); EXPECT_FALSE(ReaderStatusBarTapTarget::containsTop(8, 0, 0, 19)); } + +TEST(ReaderStatusBarTapTarget, TopAndBottomCanBeTappedIndependently) { + EXPECT_TRUE(ReaderStatusBarTapTarget::containsTop(16, 480, 8, 24)); + EXPECT_FALSE(ReaderStatusBarTapTarget::containsBottom(16, 480, 5, 24)); + EXPECT_TRUE(ReaderStatusBarTapTarget::containsBottom(460, 480, 5, 24)); + EXPECT_FALSE(ReaderStatusBarTapTarget::containsTop(460, 480, 8, 24)); +} diff --git a/test/release_suppression/ReleaseSuppressionTest.cpp b/test/release_suppression/ReleaseSuppressionTest.cpp index 6c65b2b094a..09de772fcd0 100644 --- a/test/release_suppression/ReleaseSuppressionTest.cpp +++ b/test/release_suppression/ReleaseSuppressionTest.cpp @@ -66,4 +66,26 @@ TEST(ReleaseSuppressionTest, PowerConfirmSuppressionExpiresWithoutAConsumer) { EXPECT_FALSE(suppression.consumePowerConfirmRelease()); } +TEST(ReleaseSuppressionTest, NavigationHoldSwallowsOnlyItsOwnMenuOrBackRelease) { + for (const bool fromMenu : {false, true}) { + ReleaseSuppression suppression; + if (fromMenu) + suppression.suppressConfirm(); + else + suppression.suppressBack(); + + suppression.expireAfterReleaseFrame(fromMenu ? ReleaseSuppression::FrameState{.confirmHeld = true} + : ReleaseSuppression::FrameState{.backHeld = true}); + suppression.expireAfterReleaseFrame(fromMenu ? ReleaseSuppression::FrameState{.confirmReleased = true} + : ReleaseSuppression::FrameState{.backReleased = true}); + if (fromMenu) { + EXPECT_TRUE(suppression.consumeConfirmRelease()); + EXPECT_FALSE(suppression.consumeConfirmRelease()); + } else { + EXPECT_TRUE(suppression.consumeBackRelease()); + EXPECT_FALSE(suppression.consumeBackRelease()); + } + } +} + } // namespace diff --git a/test/touch_reader_preview/CMakeLists.txt b/test/touch_reader_preview/CMakeLists.txt index 860abef0ed4..3b2d8a165d3 100644 --- a/test/touch_reader_preview/CMakeLists.txt +++ b/test/touch_reader_preview/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable(TouchReaderPreviewModelTest target_include_directories(TouchReaderPreviewModelTest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/stubs ${REPO_ROOT}/src/activities/reader + ${REPO_ROOT}/include ) target_link_libraries(TouchReaderPreviewModelTest PRIVATE @@ -13,3 +14,7 @@ target_link_libraries(TouchReaderPreviewModelTest PRIVATE ) gtest_discover_tests(TouchReaderPreviewModelTest) + +target_compile_definitions(TouchReaderPreviewModelTest PRIVATE + SIMULATOR CROSSINK_APP_CAP_TOUCH=0 CROSSINK_APP_CAP_USB_DRIVE=0 +) diff --git a/test/touch_reader_preview/TouchReaderPreviewModelTest.cpp b/test/touch_reader_preview/TouchReaderPreviewModelTest.cpp index 74779085a5c..ac41eedcb6b 100644 --- a/test/touch_reader_preview/TouchReaderPreviewModelTest.cpp +++ b/test/touch_reader_preview/TouchReaderPreviewModelTest.cpp @@ -316,3 +316,78 @@ TEST(TouchReaderPreviewModel, KeepsBoundedPreviewWhenPageHasMoreLinesThanSnapsho EXPECT_EQ(renderer.drawCalls.size(), TouchReaderPreviewModel::LINE_CAPACITY); EXPECT_GT(renderer.drawCalls.back().x, 0); } + +TEST(SampleReaderPreviewModel, ContainsTheWholeParagraphWithoutABookPage) { + SampleReaderPreviewModel model; + ASSERT_TRUE(model.captureParagraph(READER_PREVIEW_PARAGRAPH)); + GfxRenderer renderer; + model.renderText(renderer, 1, 5, 8, 100, 100, 0, static_cast(CssTextAlign::Left), false, false, true); + std::string paragraph; + for (const auto& call : renderer.drawCalls) { + if (!paragraph.empty()) paragraph += ' '; + paragraph += call.text; + EXPECT_GE(call.x, 5); + } + EXPECT_EQ(paragraph, READER_PREVIEW_PARAGRAPH); + EXPECT_GT(renderer.drawCalls.back().y, renderer.drawCalls.front().y); + EXPECT_LE(sizeof(model), 3U * 1024U); +} + +TEST(SampleReaderPreviewModel, ReflowsForMarginsFontSizeAndSpacing) { + SampleReaderPreviewModel model; + ASSERT_TRUE(model.captureParagraph(READER_PREVIEW_PARAGRAPH)); + const auto lastY = [&](int font, int width, int lineSpacing, int wordSpacing) { + GfxRenderer renderer; + model.renderText(renderer, font, 0, 0, width, lineSpacing, wordSpacing, static_cast(CssTextAlign::Left), + false, false, true); + return renderer.drawCalls.back().y; + }; + const int normal = lastY(1, 100, 100, 0); + EXPECT_GT(lastY(1, 60, 100, 0), normal); + EXPECT_GT(lastY(2, 100, 100, 0), normal); + EXPECT_GT(lastY(1, 100, 150, 0), normal); + EXPECT_GT(lastY(1, 100, 100, 4), normal); +} + +TEST(SampleReaderPreviewModel, SupportsAlignmentFocusAndGuideDots) { + SampleReaderPreviewModel model; + ASSERT_TRUE(model.captureParagraph("Lorem ipsum dolor sit amet.")); + GfxRenderer left, right, decorated; + model.renderText(left, 1, 0, 0, 100, 100, 0, static_cast(CssTextAlign::Left), false, false, true); + model.renderText(right, 1, 0, 0, 100, 100, 0, static_cast(CssTextAlign::Right), false, false, true); + model.renderText(decorated, 1, 0, 0, 100, 100, 0, static_cast(CssTextAlign::Left), true, true, true); + EXPECT_GT(right.drawCalls.front().x, left.drawCalls.front().x); + bool bold = false, dot = false; + for (const auto& call : decorated.drawCalls) { + bold = bold || (call.style & EpdFontFamily::BOLD); + dot = dot || call.text == "·"; + } + EXPECT_TRUE(bold); + EXPECT_TRUE(dot); +} + +TEST(SampleReaderPreviewModel, RejectsOverlongSamplesWithoutLeavingAStalePreview) { + ReaderPreviewModel<32, 2, 1, false> model; + ASSERT_TRUE(model.captureParagraph("one two")); + EXPECT_FALSE(model.captureParagraph("one two three")); + EXPECT_FALSE(model.valid()); + EXPECT_FALSE(model.captureParagraph("This sample exceeds the available text capacity.")); + EXPECT_FALSE(model.valid()); + EXPECT_FALSE(model.captureParagraph(" ")); + EXPECT_FALSE(model.valid()); +} + +TEST(SampleReaderPreviewModel, DrawsOnlyCompleteLinesInsideThePreview) { + SampleReaderPreviewModel model; + ASSERT_TRUE(model.captureParagraph(READER_PREVIEW_PARAGRAPH)); + for (const int spacing : {70, 100, 200}) { + GfxRenderer renderer; + model.renderText(renderer, 1, 0, 5, 80, spacing, 0, static_cast(CssTextAlign::Left), false, false, true, + 24); + ASSERT_FALSE(renderer.drawCalls.empty()); + for (const auto& call : renderer.drawCalls) EXPECT_LE(call.y + renderer.getTextHeight(1), 24); + } + GfxRenderer tiny; + model.renderText(tiny, 1, 0, 5, 80, 100, 0, static_cast(CssTextAlign::Left), false, false, true, 10); + EXPECT_TRUE(tiny.drawCalls.empty()); +} diff --git a/test/touch_reader_preview/stubs/GfxRenderer.h b/test/touch_reader_preview/stubs/GfxRenderer.h index 1485169e25f..f7de3975b03 100644 --- a/test/touch_reader_preview/stubs/GfxRenderer.h +++ b/test/touch_reader_preview/stubs/GfxRenderer.h @@ -18,6 +18,7 @@ class GfxRenderer { mutable std::vector drawCalls; int getLineHeight(int) const { return 10; } + int getTextHeight(int) const { return 10; } int getSpaceWidth(int, EpdFontFamily::Style) const { return 1; } int getSpaceAdvance(int, uint32_t, uint32_t, EpdFontFamily::Style) const { return 1; } int getKerning(int, uint32_t, uint32_t, EpdFontFamily::Style) const { return 0; } diff --git a/web/pages/settings.css b/web/pages/settings.css index a60e37c4816..bdb2f815ddb 100644 --- a/web/pages/settings.css +++ b/web/pages/settings.css @@ -5,6 +5,48 @@ h2 { font-weight: 600; letter-spacing: -0.01em; } +.status-bar-editor { + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 12px; + margin: 14px 0; + } +.status-bar-editor h3 { + margin: 0 0 8px; + color: var(--title-color); + } +.status-bar-preview { + margin-top: 12px; + padding: 8px; + background: white; + color: black; + border: 1px solid var(--border-color); + font-size: 12px; + } +.status-bar-preview-items { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 4px; + white-space: nowrap; + overflow: hidden; + } +.status-bar-preview-items span { + overflow: hidden; + text-overflow: ellipsis; + } +.status-bar-preview-items span:nth-child(2) { text-align: center; } +.status-bar-preview-items span:nth-child(3) { text-align: right; } +.status-bar-preview-progress { + height: 3px; + margin: 3px 0; + background: #ddd; + } +.status-bar-preview-progress span { + display: block; + width: 64%; + height: 100%; + background: black; + } .setting-row { display: flex; justify-content: space-between; diff --git a/web/pages/settings.html b/web/pages/settings.html index 7447dfa184a..2433ccf465c 100644 --- a/web/pages/settings.html +++ b/web/pages/settings.html @@ -6,6 +6,8 @@ +
+ diff --git a/web/pages/settings.js b/web/pages/settings.js index c7f3a80d19f..a9d0f6289fc 100644 --- a/web/pages/settings.js +++ b/web/pages/settings.js @@ -290,6 +290,123 @@ let allSettings = []; btn.textContent = 'Save Settings'; } + // Reader bars use one complete request so the two layouts change together. + let statusBars = null; + + function statusBarSelect(id, choices, value, changed) { + return ''; + } + + function statusBarRow(label, control) { + return '
' + escapeHtml(label) + + '' + control + '
'; + } + + function renderStatusBar(position) { + const bar = statusBars[position]; + const labels = statusBars.labels; + const itemOptions = statusBars.options.map(function(option) { + return { value: option.value, label: option.label, + disabled: option.value === 1 && !statusBars.clockAvailable }; + }); + const slotNames = [labels.left + ' 1', labels.left + ' 2', labels.left + ' 3', labels.center, + labels.right + ' 1', labels.right + ' 2', labels.right + ' 3']; + let html = '

' + escapeHtml(labels[position]) + '

'; + for (let i = 0; i < bar.slots.length; i++) { + html += statusBarRow(slotNames[i], statusBarSelect('bar-' + position + '-slot-' + i, + itemOptions, bar.slots[i], 'statusBarChanged()')); + } + for (const field of [ + ['percentageFormat', labels.percentageFormat, statusBars.percentageFormats], + ['progressBar', labels.progressBar, statusBars.progressModes], + ['thickness', labels.thickness, statusBars.thicknesses] + ]) { + html += statusBarRow(field[1], statusBarSelect('bar-' + position + '-' + field[0], + field[2].map(function(label, index) { return { value: index, label: label }; }), + bar[field[0]], 'statusBarChanged()')); + } + html += '
'; + return html; + } + + function readStatusBarForm(position) { + const bar = statusBars[position]; + return { + slots: bar.slots.map(function(_, index) { + return Number(document.getElementById('bar-' + position + '-slot-' + index).value); + }), + percentageFormat: Number(document.getElementById('bar-' + position + '-percentageFormat').value), + progressBar: Number(document.getElementById('bar-' + position + '-progressBar').value), + thickness: Number(document.getElementById('bar-' + position + '-thickness').value) + }; + } + + function updateStatusBarPreview(position) { + const bar = readStatusBarForm(position); + const examples = ['', '10:30', '85%', '2h 15m', '12m', '4/12', '27', + (64.12).toFixed(bar.percentageFormat) + '%', 'Book title', 'Chapter title']; + const slot = function(index) { return escapeHtml(examples[bar.slots[index]] || ''); }; + const right = [4, 5, 6].map(slot).filter(Boolean).join('   '); + const progressValue = bar.progressBar === 0 ? 64 : 35; + const progress = bar.progressBar === 2 ? '' : '
'; + document.getElementById('bar-' + position + '-preview').innerHTML = + (position === 'top' ? progress : '') + '
' + + [slot(0), slot(1), slot(2)].filter(Boolean).join('   ') + '' + slot(3) + + '' + right + '
' + (position === 'bottom' ? progress : ''); + } + + function statusBarChanged() { + document.getElementById('statusBarsSaveBtn').disabled = false; + updateStatusBarPreview('top'); + updateStatusBarPreview('bottom'); + } + + async function loadStatusBars() { + const container = document.getElementById('status-bars-container'); + try { + const response = await fetch('/api/status-bars'); + if (!response.ok) throw new Error('Failed to load status bars'); + statusBars = await response.json(); + container.innerHTML = '

' + escapeHtml(statusBars.labels.top) + ' / ' + + escapeHtml(statusBars.labels.bottom) + '

' + renderStatusBar('top') + + renderStatusBar('bottom') + + statusBarRow(statusBars.labels.xtcMode, statusBarSelect('bar-xtc-mode', + statusBars.xtcModes.map(function(label, index) { return { value: index, label: label }; }), + statusBars.xtcMode, 'statusBarChanged()')) + + '
'; + updateStatusBarPreview('top'); + updateStatusBarPreview('bottom'); + } catch (error) { + console.error(error); + container.innerHTML = '

Failed to load status bars

'; + } + } + + async function saveStatusBars() { + const btn = document.getElementById('statusBarsSaveBtn'); + btn.disabled = true; + try { + const response = await fetch('/api/status-bars', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ top: readStatusBarForm('top'), bottom: readStatusBarForm('bottom'), + xtcMode: Number(document.getElementById('bar-xtc-mode').value) }) + }); + if (!response.ok) throw new Error(await response.text()); + showMessage('Status bars saved.', false); + await loadStatusBars(); + } catch (error) { + btn.disabled = false; + showMessage('Error: ' + error.message, true); + } + } + // --- Wi-Fi Network Management --- // Renders an editable list of saved Wi-Fi networks using /api/wifi endpoints. // Password fields are never pre-filled; when left blank during edit, existing @@ -530,6 +647,7 @@ let allSettings = []; // stall long enough to delay or interrupt a response. (async () => { await loadSettings(); + await loadStatusBars(); await loadWifiNetworks(); await loadOpdsServers(); })();