diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8630436cd0a..cfbcac505ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,14 +21,12 @@ jobs: token: ${{ secrets.RELEASE_PAT }} fetch-depth: 0 - - name: Verify release tag is on default branch + - name: Verify release tag is on main if: github.event_name == 'push' - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | - git fetch origin "$DEFAULT_BRANCH:refs/remotes/origin/$DEFAULT_BRANCH" + git fetch origin main:refs/remotes/origin/main TAG_COMMIT="$(git rev-list -n 1 "$GITHUB_REF_NAME")" - git merge-base --is-ancestor "$TAG_COMMIT" "origin/$DEFAULT_BRANCH" + git merge-base --is-ancestor "$TAG_COMMIT" origin/main - name: Resolve release version id: version @@ -85,18 +83,14 @@ jobs: run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip - name: Pin pioarduino core inside the platform penv + env: + PIO_ENV: ${{ matrix.environment }} run: | set -euo pipefail - # Envs with custom_sdkconfig (default, the light-sleep profiles) run a - # nested `pio run` from ~/.platformio/penv, which the platform creates - # with an open-ended "pioarduino>=6.1.19". That now resolves to 6.2.0, - # which needs SCons 4.11 while the platform installs SCons 4.8.1; the - # two reinstall over each other mid-build and the link fails with - # "No module named 'SCons.Tool.FortranCommon'". Pin the penv core to - # the outer core's version. The platform leaves a satisfying version - # alone. See crosspoint-reader/crosspoint-reader#3594 and - # pioarduino/platform-espressif32#529. - pio pkg install -e ${{ matrix.environment }} + # custom_sdkconfig runs a nested `pio run` from ~/.platformio/penv. + # Keep that core on the same version as the outer core. + # See https://github.com/pioarduino/platform-espressif32/issues/529 + pio pkg install -e "$PIO_ENV" uv pip install --python ~/.platformio/penv/bin/python "pioarduino==6.1.19" ~/.platformio/penv/bin/pio --version diff --git a/CHANGELOG.md b/CHANGELOG.md index afe54107002..08f14ef1e46 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. +- Turn reading stats tracking on or off for the whole device or individual EPUB and XTC books, while keeping saved history and Time Left estimates. +- Assign separate short-press and long-press actions to the Left/Up and Right/Down side buttons; existing side-button layouts migrate to matching individual actions. - 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. @@ -21,10 +23,12 @@ - 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 +- Add a Cover Grid Home theme on devices with PSRAM, showing the current book and six library covers. ### Changed - USB Drive mounts faster: the next part of the SD card is read in the background while the current data is sent over USB, cutting about 5 seconds from mounting a FAT32 card on the X4 Pro. +- Set Power short-press and long-press to Sleep, Wake, or Sleep/Wake separately; holding Power can always wake the device. Chord shortcuts and the home button can also now sleep the device. - Brightness and warmth gestures now respond while you drag, with longer swipes making larger adjustments. - Edge-slide and two-finger brightness and warmth gestures now adjust in 1% steps instead of 5% and are half as sensitive: a full-length slide changes the level by about 50%. - Reversing a brightness or warmth drag partway now moves the level past where it started instead of stopping there. @@ -46,6 +50,8 @@ - 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. +- Long status titles shorten faster when they do not fit the screen. +- Leaving an EPUB or TXT reader releases rebuildable font buffers for other screens. ### Fixed @@ -100,6 +106,16 @@ - A button press made while the end-of-book "Continue with" menu is still appearing is no longer lost: it moves the selection, opens the book, or goes back once the menu is ready. - The image viewer redraws the image after you close the pull-down top panel, the image action menu, or a prompt, instead of leaving the panel or menu on screen. - 8-bit and other paletted BMPs saved with a newer (V4/V5) header, as GIMP and ImageMagick write them, now show their real gray levels. White backgrounds no longer turn into dither dots and black no longer shows as dark gray, in the image viewer and on sleep and boot screens. +- Release builds use the pinned PlatformIO core during nested ESP-IDF configuration. +- Adding the sleep moon to the last screen no longer flashes white in night mode. +- Waking the reader skips the intermediate loading icon refresh. +- Screenshot folder names keep complete non-English characters when shortened. +- Longer power-on instructions wrap on the finished update screen. +- Sticky now records periodic heap and PSRAM statistics over its ROM logging path. +- RTL EPUBs use reading-order swipe and tap directions. +- Korean text keeps natural syllable spacing when justified and wraps by word. +- Footnote choices can be selected directly on the reading page, with a list fallback for links without a visible target. +- Changing global font or page layout settings from the pull-down panel on touch devices now updates the open book when it inherits those settings. ## [v1.6.0] - 2026-09-21 diff --git a/docs/data-cache.md b/docs/data-cache.md index 3ab80f4c1af..a7dc1f33a28 100644 --- a/docs/data-cache.md +++ b/docs/data-cache.md @@ -36,6 +36,7 @@ The main data directory is `.crosspoint` on the SD card. It stores render caches │ ├── progress.bin.bak # Reading position slot B; the newer valid slot wins │ ├── stats.bin # Legacy per-book reading stats │ ├── stats_v5.bin # Version 5 per-book reading stats +│ ├── reading_stats_off # Present when stats tracking is disabled for this book │ ├── reader_settings.bin # Per-book reader settings, render mode, and auto-page-turn interval │ ├── cover.bmp # Book cover image, once generated │ ├── cover_absolute.bmp # Four-tone image-mode cover (generated separately) diff --git a/docs/file-formats.md b/docs/file-formats.md index d99274ff543..676f8c24127 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -482,12 +482,18 @@ Binary layout: ## `section.bin` +### Version 79 + +Version 79 keeps the version 78 serialized layout. Korean words now wrap at +spaces by default; with hyphenation enabled, they can also split at a legal +CJK boundary at a line end without a visible hyphen. Justification stretches +word spaces only. Full caches (byte `79`) and suspended partial caches +(`0xF4`) both rebuild because earlier page positions are no longer valid. + ### Version 78 -Version 78 keeps the serialized layout unchanged. It was bumped because inline -CSS padding now affects dialogue and other styled text positions. Complete files -use byte `78`; suspended partials use the previously unused sentinel `0xF2`, so -version 77 partials (`0xF3`) rebuild instead of resuming under the new layout. +Version 78 changed layout for inline CSS padding. Full and suspended partial +section caches rebuild together. ### Version 77 @@ -612,7 +618,7 @@ import std.mem; import std.string; import std.core; -#define EXPECTED_VERSION 77 +#define EXPECTED_VERSION 79 #define MAX_STRING_LENGTH 65535 #define FOOTNOTE_NUMBER_LEN 32 #define FOOTNOTE_HREF_LEN 96 diff --git a/docs/reader-features.md b/docs/reader-features.md index adb66ca4e78..61ab2cee24f 100644 --- a/docs/reader-features.md +++ b/docs/reader-features.md @@ -246,7 +246,15 @@ clipping and its highlight, not an entry already exported to ## Reading Stats -CrossInk tracks per-book reading stats automatically and aggregates them into global stats. +CrossInk tracks per-book reading stats automatically and aggregates them into global stats for EPUB and XTC files. + +### Turn tracking on or off + +The device-wide switch is in **Settings > System > Reading Stats > Track Reading Stats**. When it is off, CrossInk stops recording new per-book and all-time stats. It does not delete saved stats or saved Time Left pace estimates. Turning tracking back on makes the saved history available again and resumes recording for books whose own tracking is enabled. Reading time and pages from the period when tracking was off are not added later. + +While device-wide tracking is off, recorded-stat actions and displays are hidden from the menus and home themes that normally show them. The Dashboard can still show a saved Time Left estimate when one is available. The **Reading Stats** and **Minimal Stats** sleep screens fall back to the regular Minimal sleep screen when tracking is off device-wide or for the recent book. The tracking switch remains available in Settings so it can be turned back on. + +You can also pause stats for one EPUB or XTC book while device-wide tracking is on. For an EPUB, use **Track Reading Stats** in the Reader Menu's **Settings** tab; for an XTC book, use **Track Reading Stats** in its Reader Menu. You can also use the book's Library/File Browser action menu when available. This preserves the book's saved stats and stops adding stats for that book, including its contribution to all-time totals. While paused, that book's **Reading Stats** view and book-specific theme display are hidden; its saved Time Left estimate may still appear in the Dashboard. Re-enable tracking to show the saved book history and resume recording. Tracked stats include: diff --git a/docs/user-guide.md b/docs/user-guide.md index ef2d7558071..4b28e91d51a 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -93,8 +93,7 @@ Alternatively, while reading a book, press the **Confirm** button to open the re ### Power On / Off -To turn the device on or off, **press and hold the Power button for approximately half a second**. -In **Settings > Controls > Power Button** you can configure the power button to turn the device off with a short press instead of a long one. +By default, **press and hold the Power button for approximately half a second** to put the device to sleep or wake it. In **Settings > Controls > Power Button**, you can configure the short-press and long-press actions separately. A short press wakes the device only when **Short-press Action** is set to **Sleep/Wake** or **Wake**; otherwise, hold Power for about half a second to wake it. See [Controls](#363-controls) for details. To reboot the device (for example after a firmware update or if it's frozen), press and release the Reset button, and then quickly press and hold the Power button for a few seconds. @@ -124,7 +123,7 @@ The Browse Files screen acts as a file and folder browser. The full path to the - **Navigate List:** Use **Left** (or **Up**), or **Right** (or **Down**) to move the selection cursor up and down through folders and books. You can also long-press these buttons to scroll a full page up or down. - **Open Selection:** Press **Confirm** to open a folder or start reading a selected book. Selecting a `.bmp` file will open the image viewer. - **Delete Files or Folders:** Hold and release **Confirm** to open the selected file or folder action menu, then choose **Delete**. You will be given an option to either confirm or cancel. Folder deletion is limited to empty folders. -- **Book Actions:** EPUB and XTC files can also show options such as **Delete Cache** or **Mark Finished** from the same action menu. +- **Book Actions:** EPUB and XTC files can also show options such as **Delete Cache**, **Mark Finished**, and **Track Reading Stats** from the same action menu. Turning tracking off for a book keeps its saved stats; it only stops new tracking for that book. ### 3.4 Recent Books Screen @@ -189,9 +188,9 @@ device model and build. - "None" - A blank screen - "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise - "Page Overlay" - Uses an image to overlay on top of the current page. Best used with transparent `.png` files or black and white `.bmp` files. - - "Reading Stats" - Recent reading stats on the sleep screen + - "Reading Stats" - Recent reading stats on the sleep screen; falls back to **Minimal** while tracking is off for the device or recent book - "Minimal" - A minimal sleep screen - - "Minimal Stats" - A minimal stats sleep screen on supported devices + - "Minimal Stats" - A minimal stats sleep screen on supported devices; falls back to **Minimal** while tracking is off for the device or recent book - "Dashboard" - A dashboard-style sleep screen based on the Dashboard theme - "Quick Resume" - Keeps the current content visible while sleeping @@ -227,6 +226,8 @@ device model and build. - "Lyra Carousel" - A carousel-based Lyra home layout - "RoundedRaff" - A rounded theme with additional visual styling + Themes that show reading stats hide those stats while **Track Reading Stats** is off. + - **Recent Books View**: Choose whether the Recent Books screen uses a list or grid layout. - **Sunlight Fading Fix**: Configure whether to enable a software-fix for the issue where white X4 models may fade when used in direct sunlight: @@ -293,7 +294,7 @@ Book Cache** does not reset these choices. Note: This reserves 5px of left margin to your screen to provide space for the page numbers. This is only noticeable if your `Left/Right` margins are set to `5`. If the page has no publisher page number, your margins may appear uneven. -- **Hyphenation**: Whether to hyphenate text in Reading Mode; options are "ON" or "OFF". +- **Hyphenation**: Whether to hyphenate text in Reading Mode; options are "ON" or "OFF". Korean text wraps at spaces when "OFF"; when "ON", a Korean word may also split at a line end without drawing a hyphen. - **Reading Orientation**: Set the screen orientation for reading EPUB files: - "Portrait" (default) - Standard portrait orientation @@ -339,7 +340,7 @@ which status-bar items are shown. #### 3.6.3 Controls -- **Power Button**: Configure short-press and long-press power button actions. +- **Power Button**: Configure short-press and long-press actions independently. On supported devices, you can also configure the **Power + Up** shortcut. - **Front Buttons**: Configure front-button remapping, orientation awareness, reader-only long-press behavior, Back action, and Menu action. @@ -352,14 +353,16 @@ which status-bar items are shown. - **Side Button Long-press Action**: Set whether long-pressing side buttons does nothing, skips chapters, changes font size, or changes orientation. -- **Short-press Action / Long-press Action**: Controls the effect of a short or long press of the power button. Available actions include: - - "Ignore" (default) - Require a long press to turn off the device - - "Sleep" - A short press puts the device into sleep mode - - "Page Turn" - A short press in reading mode turns to the next page; a long press turns the device off +- **Short-press Action / Long-press Action**: Choose what a short press or a hold of about 0.4 seconds does. Available actions include: + - "Ignore" (default short-press action) - Do nothing for this press length + - "Sleep/Wake" (default long-press action) - Put the awake device to sleep; when selected as **Short-press Action**, also allow a short press to wake it + - "Sleep" - Put the awake device to sleep without enabling short-press wake + - "Wake" - When selected as **Short-press Action**, allow a short press to wake the device; does nothing while the device is awake + - "Page Turn" - Turn to the next page while reading - "Toggle Bookmark", "Reading Stats", "Mark Finished", "Refresh", "Change Font", "Guide Dots", "Focus Reading", "Auto Page Turn", "Sync Progress", "File Transfer", "Calibre Wireless", "Join a Network", "Create Hotspot", "Screenshot", "Dark Mode", "Browse Files", or "Save Clipping" - Run the matching action - "Footnotes" - A short press in reading mode opens the footnotes submenu; if only one footnote is present on the page, the referenced page is opened directly. The short press on the power button can be used to select the footnote in the submenu, and to go back to the original page after finish reading the footnote (like the back button). -- **Quick-return from footnotes**: Toggles on and off the quick return functionality from the footnotes. When the functionality it's active, a short press of the power button will act as the back button from the footnotes page. +- **Quick-return from Footnotes**: This setting appears under **Settings > Controls > Power Button** after you assign **Footnotes** to the short- or long-press Power action, or to the long-press Back or Menu action. When enabled, a short Power press returns from a footnote page to the original reading page. - **Taps & Gestures** (touchscreen devices): Configure the touch interactions available while reading from **Settings > Controls > Taps & Gestures**. The @@ -397,8 +400,15 @@ which status-bar items are shown. - **Files & Cache**: Configure hidden files, file extensions, file-browser view, finished-book behavior, and clear the reading cache. -- **Reading Stats**: Configure stats tracking and idle-time filtering, and - access all-time stats backup/reset actions. +- **Reading Stats**: Turn device-wide stats tracking on or off, configure + idle-time filtering, and access all-time stats backup/reset actions. Turning + tracking off pauses new per-book and all-time stats but keeps the history + and Time Left pace estimates already saved on the device. Stats entries and + recorded-stat displays in menus and themes are hidden while tracking is off; + the Dashboard can still show a saved Time Left estimate. Turn tracking back + on to see the saved history and resume recording. Time and pages from the off + period are not added later. See [Reader Features](./reader-features.md#turn-tracking-on-or-off) + for per-book controls. - **Wi-Fi Networks**: Connect to Wi-Fi networks for file transfers and firmware updates. @@ -591,17 +601,17 @@ Once any of the options above is set up, press **Confirm** while reading to open The **Sleep Screen** setting controls what is displayed when the device goes to sleep: -| Mode | Behavior | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Dark** (default) | The CrossInk logo on a dark background. | -| **Light** | The CrossInk logo on a white background. | -| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. | -| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. | -| **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. | -| **Page Overlay** | Keeps the current reader page visible and draws a sleep wallpaper over it. If no wallpaper is available, the page remains visible while reading. | -| **Minimal** | A compact sleep screen based on the Minimal home layout. | -| **Minimal Stats** | A compact sleep screen with recent reading stats, on supported devices. | -| **None** | A blank screen. | +| Mode | Behavior | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Dark** (default) | The CrossInk logo on a dark background. | +| **Light** | The CrossInk logo on a white background. | +| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. | +| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. | +| **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. | +| **Page Overlay** | Keeps the current reader page visible and draws a sleep wallpaper over it. If no wallpaper is available, the page remains visible while reading. | +| **Minimal** | A compact sleep screen based on the Minimal home layout. | +| **Minimal Stats** | A compact sleep screen with recent reading stats, on supported devices; falls back to **Minimal** while tracking is off for the device or recent book. | +| **None** | A blank screen. | #### Cover settings @@ -847,7 +857,10 @@ Available options include: - **Show page as QR** – Display a QR code encoding the current reading position. - **Delete Book Cache** – Clear the cached layout data for the current book, forcing a re-index on next open. - **Sync Progress** – Push or pull reading progress with a KOReader sync server (see [KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)). -- **Reading Stats** – Open the current book's reading stats. +- **Reading Stats** – Open the current book's reading stats on supported devices + when tracking is enabled for both the device and this book. Use the Reader + Menu's **Settings** tab in EPUB, or the Reader Menu in XTC, to pause or resume + tracking for this book; saved stats are kept while paused. - **Mark Finished / Mark Unfinished** – Toggle whether the current book is marked as finished. - **Look Up Word / Lookup History** – Select words on the page and revisit recent per-book lookups when a dictionary is active. - **Book Dictionary** – Choose a per-book dictionary override from the reader menu's settings tab. diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 7a20b845b39..e754fc3370f 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -1318,6 +1318,24 @@ bool Epub::generateThumbBmp(int width, int height, const GfxRenderer* renderer, return generateThumbBmpInternal(width, height, false, renderer, readerFontId); } +bool Epub::generateThumbBmpFromSource(int height, const GfxRenderer* renderer, const int readerFontId) { + int width = 0; + normalizeThumbDimensions(width, height); + const std::string thumbPath = getThumbBmpPathForDimensions(cachePath, width, height); + if (cachedBmpMatchesDimensions(thumbPath, width, height)) return true; + + auto metadata = makeUniqueNoThrow(); + if (!metadata) { + LOG_ERR("EBP", "Cannot allocate cover metadata"); + return false; + } + setupCacheDir(); + if (!parseContentOpf(*metadata, /*writeSpineEntries=*/false, /*collectCssFiles=*/false)) { + return false; + } + return generateThumbBmpInternal(width, height, false, renderer, readerFontId, &metadata->coverItemHref); +} + bool Epub::generateAdaptiveThumbBmp(int width, int height, const GfxRenderer* renderer, const int readerFontId) const { return generateThumbBmpInternal(width, height, true, renderer, readerFontId); } @@ -1372,7 +1390,7 @@ bool Epub::ensureCachedCoverImage(const std::string& coverImageHref, std::string } bool Epub::generateThumbBmpInternal(int width, int height, const bool adaptiveContain, const GfxRenderer* renderer, - const int readerFontId) const { + const int readerFontId, const std::string* coverHrefOverride) const { if (height <= 0) { LOG_DBG("EBP", "Using default thumb BMP height for requested dimensions: %dx%d", width, height); } @@ -1385,12 +1403,12 @@ bool Epub::generateThumbBmpInternal(int width, int height, const bool adaptiveCo return true; } - if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + if (!coverHrefOverride && (!bookMetadataCache || !bookMetadataCache->isLoaded())) { LOG_ERR("EBP", "Cannot generate thumb BMP, cache not loaded"); return false; } - const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + const auto& coverImageHref = coverHrefOverride ? *coverHrefOverride : bookMetadataCache->coreMetadata.coverItemHref; if (coverImageHref.empty()) { LOG_DBG("EBP", "No known cover image for thumbnail"); } else if (FsHelpers::hasJpgExtension(coverImageHref)) { diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index a3be73dfc67..9602ccb3bdb 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -169,6 +169,8 @@ class Epub { // thumbnail height. // Returns false on missing cache/cover, unsupported image format, or conversion failure. bool generateThumbBmp(int width, int height, const GfxRenderer* renderer = nullptr, int readerFontId = 0) const; + // Generate a Home thumbnail from the EPUB cover without building reader pages or indexes. + bool generateThumbBmpFromSource(int height, const GfxRenderer* renderer = nullptr, int readerFontId = 0); // Writes a thumbnail that can either crop-to-fill or contain unusual cover // ratios, depending on the source image dimensions. bool generateAdaptiveThumbBmp(int width, int height, const GfxRenderer* renderer = nullptr, @@ -223,5 +225,5 @@ class Epub { std::string getCachedCoverImagePath(const std::string& coverImageHref) const; bool ensureCachedCoverImage(const std::string& coverImageHref, std::string& outPath) const; bool generateThumbBmpInternal(int width, int height, bool adaptiveContain, const GfxRenderer* renderer, - int readerFontId) const; + int readerFontId, const std::string* coverHrefOverride = nullptr) const; }; diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 2fc70895cb6..e09b5fee6fb 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -161,13 +161,38 @@ uint32_t countCodepoints(const std::string_view text) { return count; } -bool hasCjkBreakOpportunityBetween(const uint32_t leftCp, const uint32_t rightCp) { +bool cjkBoundaryAllowsBreak(const uint32_t leftCp, const uint32_t rightCp) { if (!utf8IsCjkBreakable(leftCp) && !utf8IsCjkBreakable(rightCp)) return false; if (isNoBreakAfterCjkPunctuation(leftCp) || isNoBreakBeforeCjkPunctuation(rightCp)) return false; if (utf8IsCombiningMark(rightCp) || utf8IsVariationSelector(rightCp)) return false; return true; } +// Korean separates words with spaces, so a boundary touching Hangul is not a gap-less break inside +// a line. hangulLineEndBreaks() still lets a Hangul word split there at a line end. +bool hasCjkBreakOpportunityBetween(const uint32_t leftCp, const uint32_t rightCp) { + if (utf8IsHangul(leftCp) || utf8IsHangul(rightCp)) return false; + return cjkBoundaryAllowsBreak(leftCp, rightCp); +} + +// Line-end split points inside a Hangul word, using the CJK boundary rules (no hyphen is drawn). +std::vector hangulLineEndBreaks(const std::string& word) { + std::vector breaks; + if (word.empty()) return breaks; + const auto* const start = reinterpret_cast(word.c_str()); + const auto* ptr = start; + uint32_t prev = utf8NextCodepoint(&ptr); + while (*ptr) { + const size_t offset = static_cast(ptr - start); + const uint32_t cur = utf8NextCodepoint(&ptr); + if ((utf8IsHangul(prev) || utf8IsHangul(cur)) && cjkBoundaryAllowsBreak(prev, cur)) { + breaks.push_back({offset, false}); + } + prev = cur; + } + return breaks; +} + std::vector cjkCharacterBreakByteOffsets(const std::string& text) { struct CodepointBoundary { uint32_t cp; @@ -1356,8 +1381,10 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl return splitTokenAtCodepointBoundary(wordIndex, availableWidth, renderer, fontId, wordWidths); } - // Collect candidate breakpoints (byte offsets and hyphen requirements). - auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks); + // Hangul splits at legal CJK boundaries only at a line end when hyphenation is on. + auto breakInfos = hangulLineEndBreaks(word); + const auto hyphenBreaks = Hyphenator::breakOffsets(word, allowFallbackBreaks); + breakInfos.insert(breakInfos.end(), hyphenBreaks.begin(), hyphenBreaks.end()); if (breakInfos.empty()) { if (allowFallbackBreaks && allowCharacterBreaks_) { return splitTokenAtCodepointBoundary(wordIndex, availableWidth, renderer, fontId, wordWidths); diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 8858257f19f..d7a0b8eecd6 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -28,11 +28,12 @@ constexpr uint32_t SECTION_CACHE_MAGIC = 0x535843FF; // bytes: 0xFF, "CXS" // v76: Paragraphs without source CSS indentation no longer receive a synthetic indent. // v77: Ordered lists, marker suppression, and list-container insets affect page layout. // v78: Inline CSS padding affects dialogue and other styled text positions. -constexpr uint8_t SECTION_FILE_VERSION = 78; +// v79: Hangul word boundaries and line-end splits change cached page positions. +constexpr uint8_t SECTION_FILE_VERSION = 79; // Suspended incremental build: valid pages plus LUTs and a parse-watermark trailer. // Change this with layout or payload changes so stale partial pages cannot resume // under a different layout contract. -constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xF2; +constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xF4; constexpr uint32_t HEADER_SIZE = sizeof(SECTION_CACHE_MAGIC) + sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index 386a1eeb3da..39b7701c438 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -268,6 +269,8 @@ void sanitizePathComponentForFat32(const char* input, char* output, size_t maxLe output[i] = c; } } + // If the last character was cut in half, drop its leftover bytes. + i = static_cast(utf8SafeTruncateBuffer(output, static_cast(i))); output[i] = '\0'; } diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 66a10daf425..08a92b265dc 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -2460,11 +2460,38 @@ std::string GfxRenderer::truncatedText(const int fontId, const char* text, const return item; } - while (!item.empty() && getTextWidth(fontId, (item + ellipsis).c_str(), style) >= maxWidth) { - utf8RemoveLastChar(item); + size_t charCount = 0; + for (const unsigned char c : item) { + if ((c & 0xC0) != 0x80) ++charCount; } - return item.empty() ? ellipsis : item + ellipsis; + std::string candidate; + candidate.reserve(item.size() + 3); + const auto setCandidate = [&](const size_t characterCount) { + size_t end = 0; + for (size_t count = 0; end < item.size() && count < characterCount; ++count) { + ++end; + while (end < item.size() && (static_cast(item[end]) & 0xC0) == 0x80) ++end; + } + candidate.assign(item.data(), end); + candidate += ellipsis; + }; + + // Binary search avoids repeatedly measuring nearly the entire long title. + size_t low = 0; + size_t high = charCount; + while (low < high) { + const size_t mid = low + (high - low + 1) / 2; + setCandidate(mid); + if (getTextWidth(fontId, candidate.c_str(), style) < maxWidth) { + low = mid; + } else { + high = mid - 1; + } + } + + setCandidate(low); + return candidate; } std::vector GfxRenderer::wrappedText(const int fontId, const char* text, const int maxWidth, diff --git a/lib/I18n/translations/arabic.yaml b/lib/I18n/translations/arabic.yaml index 1aee80af151..b6fd664e84d 100644 --- a/lib/I18n/translations/arabic.yaml +++ b/lib/I18n/translations/arabic.yaml @@ -243,6 +243,7 @@ STR_IN_READER: "أثناء القراءة" STR_ALWAYS: "دائما" STR_IGNORE: "تجاهل" STR_SLEEP: "سكون" +STR_WAKE: "إيقاظ" STR_PAGE_TURN: "تقليب الصفحة" STR_FORCE_REFRESH: "تحديث الشاشة" STR_CHANGE_FONT: "تغيير الخط" @@ -392,6 +393,7 @@ STR_UI_THEME: "مظهر الواجهة" STR_THEME_CLASSIC: "كلاسيكي" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra موسع" STR_THEME_LYRA_CAROUSEL: "ليرا كاروسيل" STR_THEME_MINIMAL: "الحد الأدنى" @@ -757,6 +759,7 @@ STR_LIBRARY_UNSORTED: "المكتبة (غير مرتبة)" STR_LIBRARY_USE_METADATA: "استخدام بيانات الكتب" STR_LIBRARY_SETTINGS: "إعدادات المكتبة" STR_LIBRARY_LIST_VIEW: "عرض القائمة" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "عرض الكتب المفتوحة مؤخراً" STR_LIBRARY_EXPANDED: "موسع" STR_LIBRARY_SHOW_FILES: "إظهار الملفات" STR_LIBRARY_FILES_COUNT: "%u ملفًا" diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index ea509a58ca4..117349f8f44 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -187,6 +187,7 @@ STR_IN_READER: "У рэжыме чытання" STR_ALWAYS: "Заўсёды" STR_IGNORE: "Ігнараваць" STR_SLEEP: "Сон" +STR_WAKE: "Абудзіць" STR_PAGE_TURN: "Перагортванне" STR_NEXT_PAGE: "Наступная старонка" STR_PORTRAIT: "Партрэт" @@ -498,6 +499,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Малюсенькі" STR_TOP: "Зверху" STR_XTC_STATUS_BAR: "Радок стану XTC" @@ -579,6 +581,7 @@ STR_LIBRARY_UNSORTED: "Бібліятэка (без сартавання)" STR_LIBRARY_USE_METADATA: "Метаданыя кніг" STR_LIBRARY_SETTINGS: "Налады бібліятэкі" STR_LIBRARY_LIST_VIEW: "Выгляд спіса" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Выгляд нядаўна адкрытых кніг" STR_LIBRARY_EXPANDED: "Разгорнуты" STR_LIBRARY_SHOW_FILES: "Паказваць файлы" STR_LIBRARY_FILES_COUNT: "%u файлаў" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 07f10f697e9..7299a0fcf3b 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -211,6 +211,7 @@ STR_IN_READER: "Al lector" STR_ALWAYS: "Sempre" STR_IGNORE: "Ignora" STR_SLEEP: "Dormir" +STR_WAKE: "Despertar" STR_PAGE_TURN: "Canvi de pàgina" STR_PORTRAIT: "Vertical" STR_LANDSCAPE_CW: "Horitzontal horari" @@ -465,6 +466,7 @@ STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi no connectat" STR_CLOCK_SYNC_NO_WIFI_HINT: "Connecta't al Wi-Fi primer i torna-ho a provar." STR_CLOCK_SYNCED: "Rellotge sincronitzat" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_SEARCH: "Cerca" STR_SET_SLEEP_COVER: "Fixa portada" STR_ADD_SERVER: "Afegeix servidor" @@ -638,6 +640,7 @@ STR_LIBRARY_UNSORTED: "Biblioteca (sense ordenar)" STR_LIBRARY_USE_METADATA: "Usa les metadades" STR_LIBRARY_SETTINGS: "Configuració de la biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de llista" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vista dels llibres oberts recentment" STR_LIBRARY_EXPANDED: "Ampliada" STR_LIBRARY_SHOW_FILES: "Mostra fitxers" STR_LIBRARY_FILES_COUNT: "%u fitxers" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index d054b4d6fcc..a8b0dab2832 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -207,6 +207,7 @@ STR_IN_READER: "Ve čtečce" STR_ALWAYS: "Vždy" STR_IGNORE: "Ignorovat" STR_SLEEP: "Spánek" +STR_WAKE: "Probudit" STR_PAGE_TURN: "Otáčení stránek" STR_PORTRAIT: "Na výšku" STR_LANDSCAPE_CW: "Na šířku po směru hod. ručiček" @@ -290,6 +291,7 @@ STR_FILTER_CONTRAST: "Kontrast" STR_UI_THEME: "Motiv rozhraní" STR_THEME_CLASSIC: "Klasický" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Oprava blednutí na slunci" STR_QUICK_RESUME_TIMEOUT: "Rychlé navázání po vypršení" @@ -765,6 +767,7 @@ STR_LIBRARY_UNSORTED: "Knihovna (neseřazená)" STR_LIBRARY_USE_METADATA: "Použít metadata knih" STR_LIBRARY_SETTINGS: "Nastavení knihovny" STR_LIBRARY_LIST_VIEW: "Zobrazení seznamu" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Zobrazení nedávno otevřených knih" STR_LIBRARY_EXPANDED: "Rozšířené" STR_LIBRARY_SHOW_FILES: "Zobrazit soubory" STR_LIBRARY_FILES_COUNT: "%u souborů" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index 748b19e1ac8..d62a143cd8f 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -213,6 +213,7 @@ STR_IN_READER: "I læseren" STR_ALWAYS: "Altid" STR_IGNORE: "Ignorer" STR_SLEEP: "Hvile" +STR_WAKE: "Væk" STR_PAGE_TURN: "Sideskift" STR_NEXT_PAGE: "Næste side" STR_PORTRAIT: "Portræt" @@ -494,6 +495,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Lille" STR_TOGGLE_FOCUS_READING: "Fokuslæsning" STR_TOGGLE_BOOKMARK: "Skift bogmærke" @@ -578,6 +580,7 @@ STR_LIBRARY_UNSORTED: "Bibliotek (usorteret)" STR_LIBRARY_USE_METADATA: "Brug bogmetadata" STR_LIBRARY_SETTINGS: "Biblioteksindstillinger" STR_LIBRARY_LIST_VIEW: "Listevisning" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Visning af senest åbnede bøger" STR_LIBRARY_EXPANDED: "Udvidet" STR_LIBRARY_SHOW_FILES: "Vis filer" STR_LIBRARY_FILES_COUNT: "%u filer" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 94acffbe250..7b2e91db069 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -212,6 +212,7 @@ STR_IN_READER: "In lezer" STR_ALWAYS: "Altijd" STR_IGNORE: "Negeren" STR_SLEEP: "Slaap" +STR_WAKE: "Wekken" STR_PAGE_TURN: "Pagina omslaan" STR_NEXT_PAGE: "Volgende pagina" STR_PORTRAIT: "Staand" @@ -493,6 +494,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Klein" STR_TOGGLE_FOCUS_READING: "Gefocust lezen" STR_TOGGLE_BOOKMARK: "Schakel bladwijzer in" @@ -577,6 +579,7 @@ STR_LIBRARY_UNSORTED: "Bibliotheek (ongesorteerd)" STR_LIBRARY_USE_METADATA: "Boekmetadata gebruiken" STR_LIBRARY_SETTINGS: "Bibliotheekinstellingen" STR_LIBRARY_LIST_VIEW: "Lijstweergave" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Weergave recent geopende boeken" STR_LIBRARY_EXPANDED: "Uitgebreid" STR_LIBRARY_SHOW_FILES: "Bestanden tonen" STR_LIBRARY_FILES_COUNT: "%u bestanden" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index c3cc2633c36..a77a2d8a645 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -337,6 +337,7 @@ STR_IN_READER: "In Reader" STR_ALWAYS: "Always" STR_IGNORE: "Ignore" STR_SLEEP: "Sleep" +STR_WAKE: "Wake" STR_PAGE_TURN: "Page Turn" STR_FORCE_REFRESH: "Refresh Screen" STR_CHANGE_FONT: "Change Font" @@ -529,6 +530,7 @@ STR_UI_SCALE: "UI Scale" STR_THEME_CLASSIC: "Classic" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" @@ -902,6 +904,7 @@ STR_LIBRARY_SEARCH_FAILED: "Search unavailable" STR_LIBRARY_SETTINGS: "Library Settings" STR_LIBRARY_FINISHED_BOOKS: "Finished Books" STR_LIBRARY_LIST_VIEW: "List View" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Recently Opened View" STR_LIBRARY_EXPANDED: "Expanded" STR_LIBRARY_SHOW_FILES: "Show Files" STR_LIBRARY_FILES_COUNT: "%u files" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 9a99466bcab..cd80f6b6d55 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -204,6 +204,7 @@ STR_IN_READER: "Lukijassa" STR_ALWAYS: "Aina" STR_IGNORE: "Ohita" STR_SLEEP: "Lepotila" +STR_WAKE: "Herätä" STR_PAGE_TURN: "Sivunkääntö" STR_NEXT_PAGE: "Seuraava sivu" STR_PORTRAIT: "Pysty" @@ -482,6 +483,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Pieni" STR_TITLE: "Otsikko" STR_TOGGLE_FOCUS_READING: "Keskittynyt lukeminen" @@ -567,6 +569,7 @@ STR_LIBRARY_UNSORTED: "Kirjasto (järjestämätön)" STR_LIBRARY_USE_METADATA: "Käytä kirjan metatietoja" STR_LIBRARY_SETTINGS: "Kirjaston asetukset" STR_LIBRARY_LIST_VIEW: "Luettelonäkymä" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Äskettäin avattujen kirjojen näkymä" STR_LIBRARY_EXPANDED: "Laajennettu" STR_LIBRARY_SHOW_FILES: "Näytä tiedostot" STR_LIBRARY_FILES_COUNT: "%u tiedostoa" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 4ba6334b7f2..bec5e124667 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -213,6 +213,7 @@ STR_IN_READER: "Dans le lecteur" STR_ALWAYS: "Toujours" STR_IGNORE: "Ignorer" STR_SLEEP: "Mise en veille" +STR_WAKE: "Réveiller" STR_PAGE_TURN: "Page suivante" STR_NEXT_PAGE: "Page suivante" STR_PORTRAIT: "Portrait" @@ -323,6 +324,7 @@ STR_RECENT_BOOKS_VIEW: "Vue des livres récents" STR_LIST_VIEW: "Liste" STR_GRID_VIEW: "Grille" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Correction lisibilité au soleil" STR_QUICK_RESUME_TIMEOUT: "Reprise rapide après délai" @@ -578,6 +580,7 @@ STR_LIBRARY_UNAVAILABLE: "Livre indisponible" STR_LIBRARY_SEARCH_FAILED: "Recherche indisponible" STR_LIBRARY_SETTINGS: "Paramètres de la bibliothèque" STR_LIBRARY_LIST_VIEW: "Vue en liste" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vue des livres ouverts récemment" STR_LIBRARY_EXPANDED: "Développée" STR_LIBRARY_SHOW_FILES: "Afficher les fichiers" STR_LIBRARY_FILES_COUNT: "%u fichiers" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index eb3df24f2e5..67a02a39ed6 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -249,6 +249,7 @@ STR_IN_READER: "Beim Lesen" STR_ALWAYS: "Immer" STR_IGNORE: "Ignorieren" STR_SLEEP: "Standby" +STR_WAKE: "Aufwecken" STR_PAGE_TURN: "Umblättern" STR_FORCE_REFRESH: "Anzeige aktualisieren" STR_CHANGE_FONT: "Schriftart ändern" @@ -401,6 +402,7 @@ STR_UI_SCALE: "Größe Nutzeroberfläche" STR_THEME_CLASSIC: "Klassisch" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" @@ -755,6 +757,7 @@ STR_LIBRARY_UNAVAILABLE: "Buch nicht verfügbar" STR_LIBRARY_SEARCH_FAILED: "Suche nicht verfügbar" STR_LIBRARY_SETTINGS: "Bibliothekseinstellungen" STR_LIBRARY_LIST_VIEW: "Listenansicht" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Ansicht zuletzt geöffneter Bücher" STR_LIBRARY_EXPANDED: "Erweitert" STR_LIBRARY_SHOW_FILES: "Dateien anzeigen" STR_LIBRARY_FILES_COUNT: "%u Dateien" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index e82bd4c4abc..68578bc0c2a 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -200,6 +200,7 @@ STR_IN_READER: "בזמן קריאה" STR_ALWAYS: "תמיד" STR_IGNORE: "התעלם" STR_SLEEP: "מצב שינה" +STR_WAKE: "להעיר" STR_PAGE_TURN: "העברת דף" STR_FORCE_REFRESH: "רענון מסך מלא" STR_PORTRAIT: "לאורך" @@ -310,6 +311,7 @@ STR_UI_THEME: "ערכת נושא" STR_THEME_CLASSIC: "קלאסי" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra מורחב" STR_SUNLIGHT_FADING_FIX: "תיקון דהיית מסך בשמש" STR_REMAP_FRONT_BUTTONS: "שנה תפקיד לכפתורים קדמיים" @@ -575,6 +577,7 @@ STR_LIBRARY_USE_METADATA: "שימוש במטא־נתונים" STR_REMOVE_FROM_RECENTS_ACTION: "הסרה מההיסטוריה" STR_LIBRARY_SETTINGS: "הגדרות ספרייה" STR_LIBRARY_LIST_VIEW: "תצוגת רשימה" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "תצוגת ספרים שנפתחו לאחרונה" STR_LIBRARY_EXPANDED: "מורחב" STR_LIBRARY_SHOW_FILES: "הצגת קבצים" STR_LIBRARY_FILES_COUNT: "%u קבצים" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 094af4fbaa5..7b8ee7dd0f6 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -212,6 +212,7 @@ STR_IN_READER: "Olvasóban" STR_ALWAYS: "Mindig" STR_IGNORE: "Mellőzés" STR_SLEEP: "Alvás" +STR_WAKE: "Ébresztés" STR_PAGE_TURN: "Lapozás" STR_NEXT_PAGE: "Következő oldal" STR_PORTRAIT: "Álló" @@ -496,6 +497,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Apró" STR_TOGGLE_FOCUS_READING: "Fókuszált olvasás" STR_TOGGLE_BOOKMARK: "Kapcsolja be a Könyvjelzőt" @@ -578,6 +580,7 @@ STR_LIBRARY_UNSORTED: "Könyvtár (rendezetlen)" STR_LIBRARY_USE_METADATA: "Könyvmetaadatok használata" STR_LIBRARY_SETTINGS: "Könyvtárbeállítások" STR_LIBRARY_LIST_VIEW: "Lista nézet" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Nemrég megnyitott könyvek nézete" STR_LIBRARY_EXPANDED: "Bővített" STR_LIBRARY_SHOW_FILES: "Fájlok megjelenítése" STR_LIBRARY_FILES_COUNT: "%u fájl" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index e473fc17e55..4ef5365fef9 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -213,6 +213,7 @@ STR_IN_READER: "Nel lettore" STR_ALWAYS: "Sempre" STR_IGNORE: "Ignora" STR_SLEEP: "Sospendi" +STR_WAKE: "Riattiva" STR_PAGE_TURN: "Cambio pagina" STR_FORCE_REFRESH: "Refresh" STR_PORTRAIT: "Verticale" @@ -327,6 +328,7 @@ STR_UI_THEME: "Tema interfaccia" STR_THEME_CLASSIC: "Classico" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra esteso" STR_SUNLIGHT_FADING_FIX: "Correzione luce solare" STR_QUICK_RESUME_TIMEOUT: "Ripresa rapida dopo timeout" @@ -648,6 +650,7 @@ STR_LIBRARY_UNSORTED: "Biblioteca (non ordinata)" STR_LIBRARY_USE_METADATA: "Usa metadati dei libri" STR_LIBRARY_SETTINGS: "Impostazioni libreria" STR_LIBRARY_LIST_VIEW: "Vista elenco" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vista libri aperti di recente" STR_LIBRARY_EXPANDED: "Espansa" STR_LIBRARY_SHOW_FILES: "Mostra file" STR_LIBRARY_FILES_COUNT: "%u file" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 2108af27e81..7e9629eaed8 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -200,6 +200,7 @@ STR_IN_READER: "Оқырманда" STR_ALWAYS: "Әрқашан" STR_IGNORE: "Елемеу" STR_SLEEP: "Ұйқы" +STR_WAKE: "Ояту" STR_PAGE_TURN: "Бет аудару" STR_NEXT_PAGE: "Келесі бет" STR_PORTRAIT: "Тік бағдар" @@ -494,6 +495,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Кішкентай" STR_TOGGLE_FOCUS_READING: "Зейінді оқу" STR_TOGGLE_BOOKMARK: "Бетбелгі қосу/өшіру" @@ -578,6 +580,7 @@ STR_LIBRARY_UNSORTED: "Кітапхана (сұрыпталмаған)" STR_LIBRARY_USE_METADATA: "Кітап метадеректерін қолдану" STR_LIBRARY_SETTINGS: "Кітапхана параметрлері" STR_LIBRARY_LIST_VIEW: "Тізім көрінісі" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Жақында ашылған кітаптар көрінісі" STR_LIBRARY_EXPANDED: "Кеңейтілген" STR_LIBRARY_SHOW_FILES: "Файлдарды көрсету" STR_LIBRARY_FILES_COUNT: "%u файл" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 4262752b9fb..c6abdb77cbc 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -210,6 +210,7 @@ STR_IN_READER: "Skaitytuve" STR_ALWAYS: "Visada" STR_IGNORE: "Nepaisyti" STR_SLEEP: "Miegas" +STR_WAKE: "Pažadinti" STR_PAGE_TURN: "Versti psl." STR_NEXT_PAGE: "Kitas psl." STR_PORTRAIT: "Stačias" @@ -494,6 +495,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Mažytis" STR_TOGGLE_FOCUS_READING: "Sufokusuotas skaitymas" STR_TOGGLE_BOOKMARK: "Perjungti žymę" @@ -578,6 +580,7 @@ STR_LIBRARY_UNSORTED: "Biblioteka (nerikiuota)" STR_LIBRARY_USE_METADATA: "Naudoti knygų metaduomenis" STR_LIBRARY_SETTINGS: "Bibliotekos nustatymai" STR_LIBRARY_LIST_VIEW: "Sąrašo rodinys" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Neseniai atidarytų knygų rodinys" STR_LIBRARY_EXPANDED: "Išplėstinis" STR_LIBRARY_SHOW_FILES: "Rodyti failus" STR_LIBRARY_FILES_COUNT: "%u failų" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 413170e5dbc..5763f149e14 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -213,6 +213,7 @@ STR_IN_READER: "W czytniku" STR_ALWAYS: "Zawsze" STR_IGNORE: "Ignoruj" STR_SLEEP: "Uśpienie" +STR_WAKE: "Wybudź" STR_PAGE_TURN: "Nast. str." STR_NEXT_PAGE: "Następna strona" STR_PORTRAIT: "Pionowo" @@ -514,6 +515,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Malutki" STR_TOGGLE_FOCUS_READING: "Czytanie skupione" STR_TOGGLE_BOOKMARK: "Przełącz zakładkę" @@ -598,6 +600,7 @@ STR_LIBRARY_UNSORTED: "Biblioteka (nieposortowana)" STR_LIBRARY_USE_METADATA: "Używaj metadanych książek" STR_LIBRARY_SETTINGS: "Ustawienia biblioteki" STR_LIBRARY_LIST_VIEW: "Widok listy" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Widok ostatnio otwartych książek" STR_LIBRARY_EXPANDED: "Rozszerzony" STR_LIBRARY_SHOW_FILES: "Pokaż pliki" STR_LIBRARY_FILES_COUNT: "%u plików" diff --git a/lib/I18n/translations/portuguese-BR.yaml b/lib/I18n/translations/portuguese-BR.yaml index 4bcd72b5f91..8fd3bbf02cc 100644 --- a/lib/I18n/translations/portuguese-BR.yaml +++ b/lib/I18n/translations/portuguese-BR.yaml @@ -237,6 +237,7 @@ STR_IN_READER: "No leitor" STR_ALWAYS: "Sempre" STR_IGNORE: "Ignorar" STR_SLEEP: "Repouso" +STR_WAKE: "Despertar" STR_PAGE_TURN: "Virar página" STR_FORCE_REFRESH: "Atualizar tela" STR_CHANGE_FONT: "Alterar fonte" @@ -386,6 +387,7 @@ STR_UI_THEME: "Tema da interface" STR_THEME_CLASSIC: "Clássico" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" @@ -753,6 +755,7 @@ STR_LIBRARY_UNSORTED: "Biblioteca (sem ordem)" STR_LIBRARY_USE_METADATA: "Usar metadados dos livros" STR_LIBRARY_SETTINGS: "Configurações da biblioteca" STR_LIBRARY_LIST_VIEW: "Visualização em lista" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Visualização de livros abertos recentemente" STR_LIBRARY_EXPANDED: "Expandida" STR_LIBRARY_SHOW_FILES: "Mostrar arquivos" STR_LIBRARY_FILES_COUNT: "%u arquivos" diff --git a/lib/I18n/translations/portuguese-PT.yaml b/lib/I18n/translations/portuguese-PT.yaml index 7e34bbf8c0c..9bb895a2a7d 100644 --- a/lib/I18n/translations/portuguese-PT.yaml +++ b/lib/I18n/translations/portuguese-PT.yaml @@ -312,6 +312,7 @@ STR_IN_READER: "No leitor" STR_ALWAYS: "Sempre" STR_IGNORE: "Ignorar" STR_SLEEP: "Repouso" +STR_WAKE: "Despertar" STR_PAGE_TURN: "Virar página" STR_FORCE_REFRESH: "Atualizar o ecrã" STR_CHANGE_FONT: "Alterar o tipo de letra" @@ -480,6 +481,7 @@ STR_UI_SCALE: "Escala da interface" STR_THEME_CLASSIC: "Clássico" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" @@ -849,6 +851,7 @@ STR_LIBRARY_UNSORTED: "Biblioteca (sem ordem)" STR_LIBRARY_USE_METADATA: "Usar metadados dos livros" STR_LIBRARY_SETTINGS: "Definições da biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de lista" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vista de livros abertos recentemente" STR_LIBRARY_EXPANDED: "Expandida" STR_LIBRARY_SHOW_FILES: "Mostrar ficheiros" STR_LIBRARY_FILES_COUNT: "%u ficheiros" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 2a5fa150119..eb517f6ec28 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -212,6 +212,7 @@ STR_IN_READER: "În lectură" STR_ALWAYS: "Întotdeauna" STR_IGNORE: "Ignoră" STR_SLEEP: "Repaus" +STR_WAKE: "Trezire" STR_PAGE_TURN: "Răsfoire pagină" STR_NEXT_PAGE: "Pagina următoare" STR_PORTRAIT: "Vertical" @@ -493,6 +494,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Micut" STR_TOGGLE_FOCUS_READING: "Lectură concentrată" STR_TOGGLE_BOOKMARK: "Comutați Marcaj" @@ -577,6 +579,7 @@ STR_LIBRARY_UNSORTED: "Bibliotecă (nesortată)" STR_LIBRARY_USE_METADATA: "Folosește metadatele cărților" STR_LIBRARY_SETTINGS: "Setări bibliotecă" STR_LIBRARY_LIST_VIEW: "Vizualizare listă" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vizualizare cărți deschise recent" STR_LIBRARY_EXPANDED: "Extinsă" STR_LIBRARY_SHOW_FILES: "Afișează fișiere" STR_LIBRARY_FILES_COUNT: "%u fișiere" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 7e1d91515e1..e6ac54eb909 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -214,6 +214,7 @@ STR_IN_READER: "В режиме чтения" STR_ALWAYS: "Всегда" STR_IGNORE: "Игнорировать" STR_SLEEP: "Сон" +STR_WAKE: "Пробудить" STR_PAGE_TURN: "Перелистывание" STR_FORCE_REFRESH: "Обновление экрана" STR_PORTRAIT: "Портрет" @@ -349,6 +350,7 @@ STR_RECENT_BOOKS_VIEW: "Вид недавних книг" STR_LIST_VIEW: "Список" STR_GRID_VIEW: "Сетка" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Компенсация выцветания" STR_REMAP_FRONT_BUTTONS: "Переназначить передние кнопки" @@ -645,6 +647,7 @@ STR_LIBRARY_UNSORTED: "Библиотека (без сортировки)" STR_LIBRARY_USE_METADATA: "Метаданные книг" STR_LIBRARY_SETTINGS: "Настройки библиотеки" STR_LIBRARY_LIST_VIEW: "Вид списка" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Вид недавно открытых книг" STR_LIBRARY_EXPANDED: "Расширенный" STR_LIBRARY_SHOW_FILES: "Показывать файлы" STR_LIBRARY_FILES_COUNT: "%u файлов" diff --git a/lib/I18n/translations/slovak.yaml b/lib/I18n/translations/slovak.yaml index 73e8a9152ce..d9236868638 100644 --- a/lib/I18n/translations/slovak.yaml +++ b/lib/I18n/translations/slovak.yaml @@ -200,6 +200,7 @@ STR_IN_READER: "V čítačke" STR_ALWAYS: "Vždy" STR_IGNORE: "Ignorovať" STR_SLEEP: "Spánok" +STR_WAKE: "Prebudiť" STR_PAGE_TURN: "Otáčanie stránok" STR_FORCE_REFRESH: "Obnoviť obrazovku" STR_CHANGE_FONT: "Zmeniť písmo" @@ -554,6 +555,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TOP: "Hore" STR_XTC_STATUS_BAR: "Stavový panel XTC" STR_NEARBY_STATS_VERSION_MISMATCH: "Nesúlad verzií. Štatistiky sa nesynchronizovali" @@ -674,6 +676,7 @@ STR_LIBRARY_UNSORTED: "Knižnica (nezoradená)" STR_LIBRARY_USE_METADATA: "Použiť metadáta kníh" STR_LIBRARY_SETTINGS: "Nastavenia knižnice" STR_LIBRARY_LIST_VIEW: "Zobrazenie zoznamu" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Zobrazenie nedávno otvorených kníh" STR_LIBRARY_EXPANDED: "Rozšírené" STR_LIBRARY_SHOW_FILES: "Zobraziť súbory" STR_LIBRARY_FILES_COUNT: "%u súborov" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 1a2d3b5a888..f11ff5569bb 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -210,6 +210,7 @@ STR_IN_READER: "V bralniku" STR_ALWAYS: "Vedno" STR_IGNORE: "Prezri" STR_SLEEP: "Spanje" +STR_WAKE: "Prebudi" STR_PAGE_TURN: "Obračanje strani" STR_NEXT_PAGE: "Naslednja stran" STR_PORTRAIT: "Pokončno" @@ -494,6 +495,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Majhen" STR_TOGGLE_FOCUS_READING: "Fokusirano branje" STR_TOGGLE_BOOKMARK: "Preklopi med zaznamke" @@ -578,6 +580,7 @@ STR_LIBRARY_UNSORTED: "Knjižnica (nerazvrščena)" STR_LIBRARY_USE_METADATA: "Uporabi metapodatke knjig" STR_LIBRARY_SETTINGS: "Nastavitve knjižnice" STR_LIBRARY_LIST_VIEW: "Pogled seznama" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Pogled nedavno odprtih knjig" STR_LIBRARY_EXPANDED: "Razširjen" STR_LIBRARY_SHOW_FILES: "Prikaži datoteke" STR_LIBRARY_FILES_COUNT: "%u datotek" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index dadab4af71f..c29c5bda3be 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -238,6 +238,7 @@ STR_IN_READER: "En el lector" STR_ALWAYS: "Siempre" STR_IGNORE: "Ignorar" STR_SLEEP: "Suspender" +STR_WAKE: "Despertar" STR_PAGE_TURN: "Pasar página" STR_FORCE_REFRESH: "Refrescar pant." STR_CHANGE_FONT: "Cambiar fuente" @@ -387,6 +388,7 @@ STR_UI_THEME: "Interfaz" STR_THEME_CLASSIC: "Clásico" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" @@ -754,6 +756,7 @@ STR_LIBRARY_UNSORTED: "Biblioteca (sin ordenar)" STR_LIBRARY_USE_METADATA: "Usar metadatos de libros" STR_LIBRARY_SETTINGS: "Ajustes de biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de lista" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vista de libros abiertos recientemente" STR_LIBRARY_EXPANDED: "Ampliada" STR_LIBRARY_SHOW_FILES: "Mostrar archivos" STR_LIBRARY_FILES_COUNT: "%u archivos" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 60255d6c405..28e00a1dcd0 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -213,6 +213,7 @@ STR_IN_READER: "I Eboksläsare" STR_ALWAYS: "Alltid" STR_IGNORE: "Ignorera" STR_SLEEP: "Vila" +STR_WAKE: "Väck" STR_PAGE_TURN: "Sidvändning" STR_FORCE_REFRESH: "Uppdatera skärmen" STR_PORTRAIT: "Porträtt" @@ -345,6 +346,7 @@ STR_RECENT_BOOKS_VIEW: "Vy för senaste böcker" STR_LIST_VIEW: "Lista" STR_GRID_VIEW: "Rutnät" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra utökad" STR_SUNLIGHT_FADING_FIX: "Fix för solskensmattning" STR_QUICK_RESUME_TIMEOUT: "Snabb återupptagning efter timeout" @@ -633,6 +635,7 @@ STR_LIBRARY_UNSORTED: "Bibliotek (osorterat)" STR_LIBRARY_USE_METADATA: "Använd bokmetadata" STR_LIBRARY_SETTINGS: "Biblioteksinställningar" STR_LIBRARY_LIST_VIEW: "Listvy" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vy för senast öppnade böcker" STR_LIBRARY_EXPANDED: "Utökad" STR_LIBRARY_SHOW_FILES: "Visa filer" STR_LIBRARY_FILES_COUNT: "%u filer" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index a16a531e769..7f885ce2b60 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -207,6 +207,7 @@ STR_IN_READER: "Okuyucuda" STR_ALWAYS: "Her Zaman" STR_IGNORE: "Yoksay" STR_SLEEP: "Uyku" +STR_WAKE: "Uyandır" STR_PAGE_TURN: "Sayfa Çevirme" STR_NEXT_PAGE: "Sonraki Sayfa" STR_PORTRAIT: "Dikey" @@ -493,6 +494,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "minik" STR_TOGGLE_FOCUS_READING: "Odaklanmış Okuma" STR_TOGGLE_BOOKMARK: "Yer İmini Değiştir" @@ -577,6 +579,7 @@ STR_LIBRARY_UNSORTED: "Kitaplık (sıralanmamış)" STR_LIBRARY_USE_METADATA: "Kitap üst verilerini kullan" STR_LIBRARY_SETTINGS: "Kitaplık ayarları" STR_LIBRARY_LIST_VIEW: "Liste görünümü" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Son açılan kitaplar görünümü" STR_LIBRARY_EXPANDED: "Genişletilmiş" STR_LIBRARY_SHOW_FILES: "Dosyaları göster" STR_LIBRARY_FILES_COUNT: "%u dosya" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 24b9a2133a9..2aedb369f5f 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -212,6 +212,7 @@ STR_IN_READER: "В читачі" STR_ALWAYS: "Завжди" STR_IGNORE: "Ігнорувати" STR_SLEEP: "Сон" +STR_WAKE: "Пробудити" STR_PAGE_TURN: "Наст. сторінка" STR_FORCE_REFRESH: "Оновити екран" STR_PORTRAIT: "Книжкова" @@ -326,6 +327,7 @@ STR_UI_THEME: "Тема інтерфейсу" STR_THEME_CLASSIC: "Класична" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_RECENT_BOOKS_VIEW: "Вигляд нещодавніх книг" STR_LIST_VIEW: "Список" @@ -631,6 +633,7 @@ STR_LIBRARY_UNSORTED: "Бібліотека (без сортування)" STR_LIBRARY_USE_METADATA: "Метадані книг" STR_LIBRARY_SETTINGS: "Налаштування бібліотеки" STR_LIBRARY_LIST_VIEW: "Вигляд списку" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Вигляд нещодавно відкритих книг" STR_LIBRARY_EXPANDED: "Розгорнутий" STR_LIBRARY_SHOW_FILES: "Показувати файли" STR_LIBRARY_FILES_COUNT: "%u файлів" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index c6c6ce454fd..6d2dbeac24d 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -212,6 +212,7 @@ STR_IN_READER: "Al lector" STR_ALWAYS: "Sempre" STR_IGNORE: "Ignora" STR_SLEEP: "Dormir" +STR_WAKE: "Despertar" STR_PAGE_TURN: "Canvi de pàgina" STR_PORTRAIT: "Vertical" STR_LANDSCAPE_CW: "Horitzontal horari" @@ -435,6 +436,7 @@ STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi no connectat" STR_CLOCK_SYNC_NO_WIFI_HINT: "Connecta't al Wi-Fi primer i torna-ho a provar." STR_CLOCK_SYNCED: "Rellotge sincronitzat" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_SEARCH: "Busca" STR_SET_SLEEP_COVER: "Fixa portada" STR_ADD_SERVER: "Afig servidor" @@ -619,6 +621,7 @@ STR_LIBRARY_USE_METADATA: "Usa les metadades" STR_REMOVE_FROM_RECENTS_ACTION: "Elimina de l’historial" STR_LIBRARY_SETTINGS: "Configuració de la biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de llista" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vista dels llibres oberts recentment" STR_LIBRARY_EXPANDED: "Ampliada" STR_LIBRARY_SHOW_FILES: "Mostra fitxers" STR_LIBRARY_FILES_COUNT: "%u fitxers" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index 12a47705e56..ac63b295bd8 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -215,6 +215,7 @@ STR_IN_READER: "Trong trình đọc" STR_ALWAYS: "Luôn luôn" STR_IGNORE: "Bỏ qua" STR_SLEEP: "Ngủ" +STR_WAKE: "Đánh thức" STR_PAGE_TURN: "Lật trang" STR_FORCE_REFRESH: "Làm mới màn hình" STR_CHANGE_FONT: "Đổi phông chữ" @@ -560,6 +561,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TOP: "Trên cùng" STR_XTC_STATUS_BAR: "Thanh trạng thái XTC" STR_NEARBY_STATS_VERSION_MISMATCH: "Phiên bản không khớp. Chưa đồng bộ thống kê" @@ -636,6 +638,7 @@ STR_LIBRARY_USE_METADATA: "Dùng siêu dữ liệu sách" STR_REMOVE_FROM_RECENTS: "Xóa khỏi lịch sử?" STR_LIBRARY_SETTINGS: "Cài đặt thư viện" STR_LIBRARY_LIST_VIEW: "Chế độ xem danh sách" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Chế độ xem sách đã mở gần đây" STR_LIBRARY_EXPANDED: "Mở rộng" STR_LIBRARY_SHOW_FILES: "Hiển thị tệp" STR_LIBRARY_FILES_COUNT: "%u tệp" diff --git a/lib/Utf8/Utf8.h b/lib/Utf8/Utf8.h index 2575367ff79..0e1f4787f39 100644 --- a/lib/Utf8/Utf8.h +++ b/lib/Utf8/Utf8.h @@ -45,9 +45,10 @@ std::string utf8CleanLookupWord(const std::string& text); // incomplete trailing bytes are excluded. int utf8SafeTruncateBuffer(const char* buf, int len); -// Returns true for CJK characters that allow line breaks on either side without hyphenation. -// Covers CJK Unified Ideographs, Hiragana, Katakana, Hangul Syllables, CJK punctuation, -// and fullwidth forms — the ranges where word boundaries are implicit per character. +// Returns true for CJK characters that break without hyphenation. Covers CJK Unified Ideographs, +// Hiragana, Katakana, Hangul, CJK punctuation, and fullwidth forms. Han and Kana have implicit +// word boundaries per character; Hangul is space-delimited, so layout keeps Hangul words whole +// (see utf8IsHangul) and splits one without a hyphen only through the hyphenation path. inline bool utf8IsCjkBreakable(const uint32_t cp) { return (cp >= 0x1100 && cp <= 0x11FF) // Hangul Jamo || (cp >= 0x3000 && cp <= 0x303F) // CJK Symbols and Punctuation @@ -66,6 +67,16 @@ inline bool utf8IsCjkBreakable(const uint32_t cp) { || (cp >= 0x2A700 && cp <= 0x2B73F); // CJK Extension C } +// Returns true for Hangul letters. Korean separates words with spaces, so a boundary touching +// Hangul is never an implicit line-break opportunity (CSS word-break: keep-all). +inline bool utf8IsHangul(const uint32_t cp) { + return (cp >= 0x1100 && cp <= 0x11FF) // Hangul Jamo + || (cp >= 0x3130 && cp <= 0x318F) // Hangul Compatibility Jamo + || (cp >= 0xA960 && cp <= 0xA97F) // Hangul Jamo Extended-A + || (cp >= 0xAC00 && cp <= 0xD7FF) // Hangul Syllables, Hangul Jamo Extended-B + || (cp >= 0xFFA0 && cp <= 0xFFDC); // Halfwidth Hangul +} + // Returns true for any codepoint in a CJK script block (Han, Kana, Hangul, Bopomofo, // radicals, and CJK punctuation/compatibility/enclosed forms). Used for fallback font // selection — deliberately broader than utf8IsCjkBreakable, whose ranges are tuned to diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index 4fde4f7d7f6..3c1fe6540df 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -329,7 +329,7 @@ bool HalGPIO::hasLeftRightButtons() const { BoardConfig::ACTIVE.input.right != BoardConfig::PIN_UNASSIGNED; } -bool HalGPIO::verifyPowerButtonWakeup(const bool shortPressWakes) { +bool HalGPIO::verifyPowerButtonWakeup(const bool shortPressWakes, const uint16_t longHoldMs) { // M5Paper v1.1 reaches setup after a normal wheel click has already been // released. Its hardware pull-ups make this ghost-wake debounce unnecessary. if (BoardConfig::isPaperMono() || BoardConfig::isM5PaperV11() || BoardConfig::ACTIVE.input.power < 0) { @@ -344,7 +344,18 @@ bool HalGPIO::verifyPowerButtonWakeup(const bool shortPressWakes) { delay(1); inputMgr.update(); } - return shortPressWakes || (heldAtFirstSample && inputMgr.isPowerButtonPhysicallyPressed()); + if (shortPressWakes) return true; + if (!heldAtFirstSample || !inputMgr.isPowerButtonPhysicallyPressed()) return false; + + // Deep sleep wakes as soon as the GPIO changes. Keep the panel and SD card + // asleep until the held press qualifies as a long Power gesture. millis() + // starts at reset, so time spent reaching this early boot check counts too. + while (millis() < longHoldMs) { + delay(1); + inputMgr.update(); + if (!inputMgr.isPowerButtonPhysicallyPressed()) return false; + } + return true; } #if FREEINK_MCU_S3 diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 35522ac2f75..f115524c29f 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -152,12 +152,11 @@ class HalGPIO { #endif void setSharedConfirmPowerShortPressEmitsPower(bool enabled); - // Verify that the physical power button remains held through input debounce. - // A device configured to sleep on a short power press can wake on that same - // short press, which has normally ended before firmware reaches this check. + // Verify the physical power button through debounce, then require a long + // hold when the saved shortcut does not permit short-press wake. // Returns true if verification succeeded, false if device should return to sleep. // Should only be called when wakeup reason is PowerButton. - bool verifyPowerButtonWakeup(bool shortPressWakes); + bool verifyPowerButtonWakeup(bool shortPressWakes, uint16_t longHoldMs); // Check if USB is connected bool isUsbConnected() const; diff --git a/platformio.ini b/platformio.ini index 037497e5155..64e06fc4e94 100644 --- a/platformio.ini +++ b/platformio.ini @@ -218,7 +218,6 @@ build_flags = ; (uses $CROSSINK_RC_HASH env var in CI, or git short hash locally) -DCROSSINK_FIRMWARE_DEVICE_TYPE=\"x3-x4\" -DCROSSINK_BUILD_ENV=\"debug\" - -DCROSSINK_ENABLE_READING_STATS_TOGGLE -DCROSSINK_SHOW_SLEEP_BUILD_INFO ; Appends battery telemetry to /battery_log.csv on wake and sleep, for ; diagnosing a gauge that misreports state of charge. Opt in for debug builds. @@ -413,7 +412,6 @@ build_flags = -DCROSSINK_FIRMWARE_DEVICE_TYPE=\"sticky\" -DENABLE_SERIAL_LOG -DCROSSPOINT_WAIT_FOR_USB_SERIAL - -DCROSSINK_ENABLE_READING_STATS_TOGGLE -DCROSSINK_SHOW_SLEEP_BUILD_INFO -DLOG_LEVEL=2 @@ -493,7 +491,6 @@ build_flags = -DUSB_PRODUCT=\"CrossInk_X4_Pro\" -DUSB_MANUFACTURER=\"CrossInk\" -DFREEINK_FB_PSRAM=1 - -DCROSSINK_ENABLE_READING_STATS_TOGGLE -DCROSSINK_SHOW_SLEEP_BUILD_INFO ; X4 Pro uses native SDMMC. Select SdFat's generic block-device seam so the ; SDK's SDMMC adapter can back the normal FsFile/FsVolume API. diff --git a/scripts/run_simulator_smoke_test.py b/scripts/run_simulator_smoke_test.py index 81955cef8f1..4d592892b91 100755 --- a/scripts/run_simulator_smoke_test.py +++ b/scripts/run_simulator_smoke_test.py @@ -35,6 +35,7 @@ "lyra_carousel": 4, "carousel": 4, "dashboard": 6, + "cover-grid": 7, } diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 2d08953b3eb..57c4cb49e73 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -471,6 +471,7 @@ void CrossPointSettings::toJson(JsonDocument& doc) const { doc["librarySortMethod"] = librarySortMethod; doc["librarySortDescending"] = librarySortDescending; doc["libraryListExpanded"] = libraryListExpanded; + doc["recentBooksView"] = recentBooksView; doc["libraryShowSeries"] = libraryShowSeries; doc["libraryShowGenre"] = libraryShowGenre; doc["libraryShowEpub"] = libraryShowEpub; @@ -609,6 +610,49 @@ bool CrossPointSettings::fromJson(JsonVariantConst doc, bool importingCrossPoint this->*(info.valuePtr) = value; } + // Old settings assigned one layout and one long-press mode to both side + // buttons. Populate only missing new keys so partially upgraded settings + // keep the user's individual choices. + const uint8_t legacySideLayout = + clamp(doc["sideButtonLayout"] | static_cast(PREV_NEXT), SIDE_BUTTON_LAYOUT_COUNT, PREV_NEXT); + const uint8_t legacySideLong = clamp(doc["sideButtonLongPress"] | static_cast(SIDE_LONG_CHAPTER_SKIP), + SIDE_LONG_PRESS_COUNT, SIDE_LONG_CHAPTER_SKIP); + const uint8_t legacyUpShort = legacySideLayout == SIDE_BUTTONS_DISABLED ? IGNORE + : legacySideLayout == PREV_NEXT ? PREVIOUS_PAGE + : PAGE_TURN; + const uint8_t legacyDownShort = legacySideLayout == SIDE_BUTTONS_DISABLED ? IGNORE + : legacySideLayout == NEXT_PREV ? PREVIOUS_PAGE + : PAGE_TURN; + const auto legacyLongAction = [&](const bool up) -> uint8_t { + switch (legacySideLong) { + case SIDE_LONG_CHAPTER_SKIP: + if (legacySideLayout == SIDE_BUTTONS_DISABLED) return IGNORE; + return (up ? legacyUpShort : legacyDownShort) == PREVIOUS_PAGE ? SIDE_PREVIOUS_CHAPTER : SIDE_NEXT_CHAPTER; + case SIDE_LONG_FONT_SIZE: + return up ? SIDE_INCREASE_FONT : SIDE_DECREASE_FONT; + case SIDE_LONG_ORIENTATION_CHANGE: + return up ? SIDE_ROTATE_COUNTERCLOCKWISE : SIDE_ROTATE_CLOCKWISE; + default: + return IGNORE; + } + }; + if (doc["sideButtonUpShort"].isNull()) { + sideButtonUpShort = legacyUpShort; + needsResave = true; + } + if (doc["sideButtonDownShort"].isNull()) { + sideButtonDownShort = legacyDownShort; + needsResave = true; + } + if (doc["sideButtonUpLong"].isNull()) { + sideButtonUpLong = legacyLongAction(true); + needsResave = true; + } + if (doc["sideButtonDownLong"].isNull()) { + sideButtonDownLong = legacyLongAction(false); + needsResave = true; + } + const auto readLibraryChoice = [&](const char* key, uint8_t& choice, const int optionCount) { const int stored = doc[key] | static_cast(choice); if (stored < 0 || stored >= optionCount) { @@ -620,6 +664,9 @@ bool CrossPointSettings::fromJson(JsonVariantConst doc, bool importingCrossPoint readLibraryChoice("librarySortMethod", librarySortMethod, 7); readLibraryChoice("librarySortDescending", librarySortDescending, 2); readLibraryChoice("libraryListExpanded", libraryListExpanded, 2); + // A missing or corrupt legacy choice uses List, even if this object loaded another document earlier. + recentBooksView = RECENT_BOOKS_LIST; + readLibraryChoice("recentBooksView", recentBooksView, RECENT_BOOKS_VIEW_COUNT); readLibraryChoice("libraryShowSeries", libraryShowSeries, 2); readLibraryChoice("libraryShowGenre", libraryShowGenre, 2); readLibraryChoice("libraryShowEpub", libraryShowEpub, 2); @@ -818,7 +865,8 @@ bool CrossPointSettings::fromJson(JsonVariantConst doc, bool importingCrossPoint const bool unavailableHomeTrigger = !gpio.hasHomeKey() && persistedQuickActionsTrigger >= static_cast(QuickActions::Trigger::TapHome) && persistedQuickActionsTrigger <= static_cast(QuickActions::Trigger::DoubleTapHome); - if (persistedQuickActionsTrigger <= static_cast(QuickActions::Trigger::UpDown) && !unavailableHomeTrigger) { + if (persistedQuickActionsTrigger <= static_cast(QuickActions::Trigger::SideDownLong) && + !unavailableHomeTrigger) { quickActionsTrigger = persistedQuickActionsTrigger; } else { quickActionsTrigger = static_cast(QuickActions::Trigger::None); @@ -1062,6 +1110,29 @@ bool CrossPointSettings::loadFromBinaryFile() { applyLegacyFrontButtonLayout(*this); } + switch (sideButtonLayout) { + case NEXT_PREV: + sideButtonUpShort = PAGE_TURN; + sideButtonDownShort = PREVIOUS_PAGE; + sideButtonUpLong = SIDE_NEXT_CHAPTER; + sideButtonDownLong = SIDE_PREVIOUS_CHAPTER; + break; + case SIDE_BUTTONS_DISABLED: + sideButtonUpShort = IGNORE; + sideButtonDownShort = IGNORE; + sideButtonUpLong = IGNORE; + sideButtonDownLong = IGNORE; + break; + case NEXT_NEXT: + sideButtonUpShort = PAGE_TURN; + sideButtonDownShort = PAGE_TURN; + sideButtonUpLong = SIDE_NEXT_CHAPTER; + sideButtonDownLong = SIDE_NEXT_CHAPTER; + break; + default: + break; + } + lineHeightPercent = legacyLineSpacingToPercent(lineSpacing, fontFamily, sdFontFamilyName[0] != '\0'); return true; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 580d27f888a..aa73fc3e912 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -177,6 +177,17 @@ class CrossPointSettings : public PersistableStore { SIDE_LONG_PRESS_COUNT }; + // Side-button actions share shortcut IDs with Power. These extra IDs are + // reader-only actions; keep their persisted values separate and stable. + enum SIDE_BUTTON_ACTION : uint8_t { + SIDE_PREVIOUS_CHAPTER = 64, + SIDE_NEXT_CHAPTER, + SIDE_INCREASE_FONT, + SIDE_DECREASE_FONT, + SIDE_ROTATE_COUNTERCLOCKWISE, + SIDE_ROTATE_CLOCKWISE, + }; + // Font family options (built-in fonts only; SD card fonts use sdFontFamilyName) enum FONT_FAMILY { LEXENDDECA = 0, BITTER = 1, FONT_FAMILY_COUNT }; static constexpr uint8_t BUILTIN_FONT_COUNT = FONT_FAMILY_COUNT; @@ -267,6 +278,9 @@ class CrossPointSettings : public PersistableStore { PREVIOUS_PAGE = 31, NEARBY_POSITION_SYNC = 32, LIBRARY = 33, + // Power-only choices. Keep SLEEP=1 as the existing Sleep/Wake setting. + SLEEP_ONLY = 34, + WAKE_ONLY = 35, SHORT_PWRBTN_COUNT }; @@ -341,7 +355,8 @@ class CrossPointSettings : public PersistableStore { LYRA_CAROUSEL = 4, MINIMAL = 5, DASHBOARD = 6, - UI_THEME_COUNT = 7 + COVER_GRID = 7, + UI_THEME_COUNT = 8 }; enum RECENT_BOOKS_VIEW { RECENT_BOOKS_LIST = 0, RECENT_BOOKS_GRID = 1, RECENT_BOOKS_VIEW_COUNT }; @@ -525,13 +540,17 @@ class CrossPointSettings : public PersistableStore { // EPUB reading orientation settings // 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise uint8_t orientation = PORTRAIT; - // Button layouts (front layout retained for migration only) + // Legacy layouts are retained for migration only. uint8_t frontButtonLayout = BACK_CONFIRM_LEFT_RIGHT; uint8_t sideButtonLayout = PREV_NEXT; uint8_t frontButtonOrientationAware = FRONT_ORIENTATION_AWARE_OFF; uint8_t sideButtonOrientationAware = 0; - // Action performed when side buttons are long-pressed in reader + // Legacy shared side-button long action, retained for migration only. uint8_t sideButtonLongPress = SIDE_LONG_CHAPTER_SKIP; + uint8_t sideButtonUpShort = PREVIOUS_PAGE; + uint8_t sideButtonUpLong = SIDE_PREVIOUS_CHAPTER; + uint8_t sideButtonDownShort = PAGE_TURN; + uint8_t sideButtonDownLong = SIDE_NEXT_CHAPTER; // Front button remap (logical -> hardware) // Used by MappedInputManager to translate logical buttons into physical front buttons. uint8_t frontButtonBack = FRONT_HW_BACK; @@ -584,7 +603,7 @@ class CrossPointSettings : public PersistableStore { uint8_t longPressButtonBehavior = OFF; // UI Theme uint8_t uiTheme = LYRA; - // Legacy Recent Books layout; retained for binary settings compatibility. + // Recently Opened layout in Library; keep the original raw values for older settings. uint8_t recentBooksView = RECENT_BOOKS_LIST; // UI scale (list fonts + row heights); touch boards default one step larger uint8_t uiScale = defaultUiScale(); @@ -672,16 +691,14 @@ class CrossPointSettings : public PersistableStore { char deviceName[21] = ""; // Quick Resume: keep current content visible with moon icon instead of showing a static sleep screen. uint8_t quickResumeSleepScreen = QUICK_RESUME_NEVER; -#ifdef CROSSINK_ENABLE_READING_STATS_TOGGLE - // Debug/test builds can disable stat writes so navigation tests do not affect personal reading stats. + // Master switch for automatic reading statistics; Time Left pace remains independent. uint8_t trackReadingStats = 1; -#endif ~CrossPointSettings() = default; - static constexpr uint16_t POWER_BUTTON_WAKE_SHORT_MS = 10; - static constexpr uint16_t POWER_BUTTON_WAKE_LONG_MS = 200; static constexpr uint16_t POWER_BUTTON_LONG_PRESS_MS = 400; + static constexpr uint16_t POWER_BUTTON_WAKE_SHORT_MS = 10; + static constexpr uint16_t POWER_BUTTON_WAKE_LONG_MS = POWER_BUTTON_LONG_PRESS_MS; static constexpr uint8_t MIN_SLEEP_TIMEOUT_MINUTES = 1; static constexpr uint8_t SLEEP_TIMEOUT_NEVER_MINUTES = 31; static constexpr uint8_t MAX_SLEEP_TIMEOUT_MINUTES = SLEEP_TIMEOUT_NEVER_MINUTES; @@ -707,22 +724,17 @@ class CrossPointSettings : public PersistableStore { static constexpr size_t MAX_DEVICE_NAME_LENGTH = sizeof(deviceName) - 1; uint16_t getPowerButtonWakeDuration() const { - return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? POWER_BUTTON_WAKE_SHORT_MS - : POWER_BUTTON_WAKE_LONG_MS; + return shortPowerPressWakes() ? POWER_BUTTON_WAKE_SHORT_MS : POWER_BUTTON_WAKE_LONG_MS; } + bool shortPowerPressWakes() const { return shortPwrBtn == SLEEP || shortPwrBtn == WAKE_ONLY; } + 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; -#else - return true; -#endif - } + bool shouldTrackReadingStats() const { return trackReadingStats != 0; } static const char* getDefaultDeviceName(); const char* getEffectiveDeviceName() const; uint16_t getReadingIdleTimeThresholdSeconds() const; diff --git a/src/GlobalActions.h b/src/GlobalActions.h index 175c6ab59f9..aa047ce1359 100644 --- a/src/GlobalActions.h +++ b/src/GlobalActions.h @@ -22,6 +22,8 @@ inline HalDisplay::RefreshMode manualScreenRefreshMode() { inline bool isPowerButtonActionAvailableOutsideReader(const CrossPointSettings::SHORT_PWRBTN action) { switch (action) { case CrossPointSettings::SHORT_PWRBTN::SLEEP: + case CrossPointSettings::SHORT_PWRBTN::SLEEP_ONLY: + case CrossPointSettings::SHORT_PWRBTN::WAKE_ONLY: case CrossPointSettings::SHORT_PWRBTN::QUICK_LOCK: case CrossPointSettings::SHORT_PWRBTN::FORCE_REFRESH: case CrossPointSettings::SHORT_PWRBTN::SYNC_PROGRESS: diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index 143fb8f8a5a..f448e1ac3f1 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -38,14 +38,6 @@ struct SideLayoutMap { ButtonIndex pageForwardSecondary; }; -// Order matches CrossPointSettings::SIDE_BUTTON_LAYOUT. -constexpr SideLayoutMap kSideLayouts[] = { - {HalGPIO::BTN_UP, kNoButton, HalGPIO::BTN_DOWN, kNoButton}, - {HalGPIO::BTN_DOWN, kNoButton, HalGPIO::BTN_UP, kNoButton}, - {kNoButton, kNoButton, kNoButton, kNoButton}, - {kNoButton, kNoButton, HalGPIO::BTN_UP, HalGPIO::BTN_DOWN}, -}; - bool shouldSwapReaderSideButtons(const bool readerMode) { return readerMode && SETTINGS.sideButtonOrientationAware && SETTINGS.orientation != CrossPointSettings::PORTRAIT; } @@ -101,18 +93,6 @@ ButtonIndex mapFrontButtonForReaderOrientation(const ButtonIndex button, const B return button; } -SideLayoutMap mapSideLayoutForReaderOrientation(SideLayoutMap side, const bool readerMode) { - if (shouldSwapReaderSideButtons(readerMode)) { - const bool hasPageBack = side.pageBackPrimary != kNoButton || side.pageBackSecondary != kNoButton; - const bool hasPageForward = side.pageForwardPrimary != kNoButton || side.pageForwardSecondary != kNoButton; - if (hasPageBack && hasPageForward) { - std::swap(side.pageBackPrimary, side.pageForwardPrimary); - std::swap(side.pageBackSecondary, side.pageForwardSecondary); - } - } - return side; -} - ButtonIndex mapSideButtonForReaderOrientation(const ButtonIndex button, const bool readerMode) { if (!shouldSwapReaderSideButtons(readerMode)) { return button; @@ -126,11 +106,6 @@ ButtonIndex mapSideButtonForReaderOrientation(const ButtonIndex button, const bo return button; } -bool readMappedSideButtons(const HalGPIO& gpio, bool (HalGPIO::*fn)(uint8_t) const, const ButtonIndex primary, - const ButtonIndex secondary) { - return (primary != kNoButton && (gpio.*fn)(primary)) || (secondary != kNoButton && (gpio.*fn)(secondary)); -} - #ifdef SIMULATOR size_t buttonIndex(MappedInputManager::Button button) { return static_cast(button); } #endif @@ -143,6 +118,17 @@ void MappedInputManager::update() const { #if CROSSINK_APP_CAP_TOUCH updateSelectionTouchDown(); #endif + if (!gpio.isPressed(HalGPIO::BTN_UP) && !gpio.wasReleased(HalGPIO::BTN_UP)) suppressPhysicalUpRelease = false; + if (!gpio.isPressed(HalGPIO::BTN_DOWN) && !gpio.wasReleased(HalGPIO::BTN_DOWN)) suppressPhysicalDownRelease = false; +} + +void MappedInputManager::suppressNextSideRelease(const Button button) { + const ButtonIndex physical = + mapSideButtonForReaderOrientation(button == Button::Up ? HalGPIO::BTN_UP : HalGPIO::BTN_DOWN, readerMode); + if (physical == HalGPIO::BTN_UP) + suppressPhysicalUpRelease = true; + else + suppressPhysicalDownRelease = true; } bool MappedInputManager::wasPhysicallyReleased(const Button button) const { @@ -166,8 +152,21 @@ void MappedInputManager::expireReleaseSuppressions() const { } bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const { - const auto sideLayout = static_cast(SETTINGS.sideButtonLayout); - const auto side = mapSideLayoutForReaderOrientation(kSideLayouts[sideLayout], readerMode); + const ButtonIndex up = mapSideButtonForReaderOrientation(HalGPIO::BTN_UP, readerMode); + const ButtonIndex down = mapSideButtonForReaderOrientation(HalGPIO::BTN_DOWN, readerMode); + const SideLayoutMap side = { + SETTINGS.sideButtonUpShort == CrossPointSettings::PREVIOUS_PAGE ? up : kNoButton, + SETTINGS.sideButtonDownShort == CrossPointSettings::PREVIOUS_PAGE ? down : kNoButton, + SETTINGS.sideButtonUpShort == CrossPointSettings::PAGE_TURN ? up : kNoButton, + SETTINGS.sideButtonDownShort == CrossPointSettings::PAGE_TURN ? down : kNoButton, + }; + const auto sideEvent = [&](const ButtonIndex physical) { + if (physical == kNoButton) return false; + if (fn == &HalGPIO::wasReleased && ((physical == HalGPIO::BTN_UP && suppressPhysicalUpRelease) || + (physical == HalGPIO::BTN_DOWN && suppressPhysicalDownRelease))) + return false; + return (gpio.*fn)(physical); + }; const ButtonIndex frontButton = mappedFrontButtonFor(button); @@ -179,19 +178,19 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint return frontButton != kNoButton && (gpio.*fn)(frontButton); case Button::Up: // Reader menus should follow the same top/bottom side-button orientation as reader page turns. - return (gpio.*fn)(mapSideButtonForReaderOrientation(HalGPIO::BTN_UP, readerMode)); + return sideEvent(up); case Button::Down: // Reader menus should follow the same top/bottom side-button orientation as reader page turns. - return (gpio.*fn)(mapSideButtonForReaderOrientation(HalGPIO::BTN_DOWN, readerMode)); + return sideEvent(down); case Button::Power: // Power button bypasses remapping. return (gpio.*fn)(HalGPIO::BTN_POWER); case Button::PageBack: // Reader page navigation uses side buttons and can be swapped via settings. - return readMappedSideButtons(gpio, fn, side.pageBackPrimary, side.pageBackSecondary); + return sideEvent(side.pageBackPrimary) || sideEvent(side.pageBackSecondary); case Button::PageForward: // Reader page navigation uses side buttons and can be swapped via settings. - return readMappedSideButtons(gpio, fn, side.pageForwardPrimary, side.pageForwardSecondary); + return sideEvent(side.pageForwardPrimary) || sideEvent(side.pageForwardSecondary); } return false; @@ -1007,6 +1006,16 @@ bool MappedInputManager::wasReleased(const Button button) const { return true; } +#ifdef SIMULATOR + if (simulatedRelease && (button == Button::Up || button == Button::Down)) { + const ButtonIndex physical = + mapSideButtonForReaderOrientation(button == Button::Up ? HalGPIO::BTN_UP : HalGPIO::BTN_DOWN, readerMode); + if ((physical == HalGPIO::BTN_UP && suppressPhysicalUpRelease) || + (physical == HalGPIO::BTN_DOWN && suppressPhysicalDownRelease)) + return false; + } +#endif + const uint8_t frontButton = mappedFrontButtonFor(button); return simulatedRelease || mapButton(button, &HalGPIO::wasReleased) || (frontButton != kNoButton && wasFrontButtonHintTapped(frontButton)); diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index 4bb656298f9..45b490b3c26 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -89,11 +89,13 @@ class MappedInputManager { void suppressNextBackRelease() { releaseSuppression.suppressBack(); } void suppressNextConfirmRelease() { releaseSuppression.suppressConfirm(); } void suppressNextPowerRelease() { releaseSuppression.suppressPower(); } + void suppressNextSideRelease(Button button); void suppressNextPowerConfirmRelease() { releaseSuppression.suppressPowerConfirm(); } bool isPowerReleaseSuppressed() const { return releaseSuppression.isPowerReleaseSuppressed(); } bool wasPressed(Button button) const; bool wasReleased(Button button) const; void injectRelease(Button button) const { injectedReleases[static_cast(button)] = true; } + bool hasInjectedRelease(Button button) const { return injectedReleases[static_cast(button)]; } void clearInjectedReleases() const { injectedReleases.fill(false); } bool isPressed(Button button) const; // Physical state is intentionally independent of release suppression. Use it @@ -291,6 +293,8 @@ class MappedInputManager { bool edgeSlideQualified = false; #endif mutable ReleaseSuppression releaseSuppression; + mutable bool suppressPhysicalUpRelease = false; + mutable bool suppressPhysicalDownRelease = false; static constexpr size_t LABEL_BUFFER_SIZE = 128; mutable std::array, 4> labelBuffers{}; // One-frame synthetic releases let a chord route through the existing diff --git a/src/QuickActions.h b/src/QuickActions.h index 6d1a1699381..fdab0682187 100644 --- a/src/QuickActions.h +++ b/src/QuickActions.h @@ -28,6 +28,10 @@ enum class Trigger : uint8_t { LongPressHome, DoubleTapHome, UpDown, + SideUpShort, + SideUpLong, + SideDownShort, + SideDownLong, }; inline constexpr std::array actionLabels = { @@ -94,6 +98,7 @@ inline constexpr std::array shortcutAction inline bool supportsTiltPageTurn() { return halTiltSensor.isAvailable(); } inline bool isActionAvailable(const uint8_t action) { + if (action == CrossPointSettings::READING_STATS && !SETTINGS.shouldTrackReadingStats()) return false; if (action == CrossPointSettings::PREVIOUS_PAGE || action == CrossPointSettings::NEARBY_POSITION_SYNC || action == CrossPointSettings::LIBRARY) return true; @@ -137,6 +142,10 @@ inline void synchronize(CrossPointSettings& settings, Trigger preferred = Trigge const bool tapHome = settings.homeButtonTapAction == CrossPointSettings::QUICK_ACTIONS; const bool longPressHome = settings.homeButtonLongPressAction == CrossPointSettings::QUICK_ACTIONS; const bool doubleTapHome = settings.homeButtonDoubleTapAction == CrossPointSettings::QUICK_ACTIONS; + const bool sideUpShort = settings.sideButtonUpShort == CrossPointSettings::QUICK_ACTIONS; + const bool sideUpLong = settings.sideButtonUpLong == CrossPointSettings::QUICK_ACTIONS; + const bool sideDownShort = settings.sideButtonDownShort == CrossPointSettings::QUICK_ACTIONS; + const bool sideDownLong = settings.sideButtonDownLong == CrossPointSettings::QUICK_ACTIONS; Trigger owner = preferred; if (owner == Trigger::None) { @@ -158,6 +167,14 @@ inline void synchronize(CrossPointSettings& settings, Trigger preferred = Trigge owner = Trigger::LongPressHome; else if (doubleTapHome) owner = Trigger::DoubleTapHome; + else if (sideUpShort) + owner = Trigger::SideUpShort; + else if (sideUpLong) + owner = Trigger::SideUpLong; + else if (sideDownShort) + owner = Trigger::SideDownShort; + else if (sideDownLong) + owner = Trigger::SideDownLong; } if (owner != Trigger::ShortPower && shortPower) settings.shortPwrBtn = CrossPointSettings::IGNORE; @@ -173,6 +190,10 @@ inline void synchronize(CrossPointSettings& settings, Trigger preferred = Trigge if (owner != Trigger::DoubleTapHome && doubleTapHome) { settings.homeButtonDoubleTapAction = CrossPointSettings::HOME_BUTTON_TOGGLE_FRONTLIGHT; } + if (owner != Trigger::SideUpShort && sideUpShort) settings.sideButtonUpShort = CrossPointSettings::IGNORE; + if (owner != Trigger::SideUpLong && sideUpLong) settings.sideButtonUpLong = CrossPointSettings::IGNORE; + if (owner != Trigger::SideDownShort && sideDownShort) settings.sideButtonDownShort = CrossPointSettings::IGNORE; + if (owner != Trigger::SideDownLong && sideDownLong) settings.sideButtonDownLong = CrossPointSettings::IGNORE; settings.quickActionsTrigger = static_cast(owner); } @@ -200,6 +221,14 @@ inline void applyTrigger(CrossPointSettings& settings, const Trigger trigger) { if (settings.homeButtonDoubleTapAction == CrossPointSettings::QUICK_ACTIONS) { settings.homeButtonDoubleTapAction = CrossPointSettings::HOME_BUTTON_TOGGLE_FRONTLIGHT; } + if (settings.sideButtonUpShort == CrossPointSettings::QUICK_ACTIONS) + settings.sideButtonUpShort = CrossPointSettings::IGNORE; + if (settings.sideButtonUpLong == CrossPointSettings::QUICK_ACTIONS) + settings.sideButtonUpLong = CrossPointSettings::IGNORE; + if (settings.sideButtonDownShort == CrossPointSettings::QUICK_ACTIONS) + settings.sideButtonDownShort = CrossPointSettings::IGNORE; + if (settings.sideButtonDownLong == CrossPointSettings::QUICK_ACTIONS) + settings.sideButtonDownLong = CrossPointSettings::IGNORE; if (trigger == Trigger::ShortPower) settings.shortPwrBtn = CrossPointSettings::QUICK_ACTIONS; if (trigger == Trigger::LongPower) settings.longPwrBtn = CrossPointSettings::QUICK_ACTIONS; @@ -210,6 +239,10 @@ inline void applyTrigger(CrossPointSettings& settings, const Trigger trigger) { if (trigger == Trigger::TapHome) settings.homeButtonTapAction = CrossPointSettings::QUICK_ACTIONS; if (trigger == Trigger::LongPressHome) settings.homeButtonLongPressAction = CrossPointSettings::QUICK_ACTIONS; if (trigger == Trigger::DoubleTapHome) settings.homeButtonDoubleTapAction = CrossPointSettings::QUICK_ACTIONS; + if (trigger == Trigger::SideUpShort) settings.sideButtonUpShort = CrossPointSettings::QUICK_ACTIONS; + if (trigger == Trigger::SideUpLong) settings.sideButtonUpLong = CrossPointSettings::QUICK_ACTIONS; + if (trigger == Trigger::SideDownShort) settings.sideButtonDownShort = CrossPointSettings::QUICK_ACTIONS; + if (trigger == Trigger::SideDownLong) settings.sideButtonDownLong = CrossPointSettings::QUICK_ACTIONS; settings.quickActionsTrigger = static_cast(trigger); } @@ -223,6 +256,10 @@ inline Trigger triggerForSetting(uint8_t CrossPointSettings::* member) { if (member == &CrossPointSettings::homeButtonTapAction) return Trigger::TapHome; if (member == &CrossPointSettings::homeButtonLongPressAction) return Trigger::LongPressHome; if (member == &CrossPointSettings::homeButtonDoubleTapAction) return Trigger::DoubleTapHome; + if (member == &CrossPointSettings::sideButtonUpShort) return Trigger::SideUpShort; + if (member == &CrossPointSettings::sideButtonUpLong) return Trigger::SideUpLong; + if (member == &CrossPointSettings::sideButtonDownShort) return Trigger::SideDownShort; + if (member == &CrossPointSettings::sideButtonDownLong) return Trigger::SideDownLong; return Trigger::None; } diff --git a/src/SettingsList.h b/src/SettingsList.h index 1e4fccbc7b2..065ea50ddef 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -19,6 +19,7 @@ #include "KOReaderCredentialStore.h" #include "QuickActions.h" #include "activities/settings/SettingsActivity.h" +#include "components/UITheme.h" #include "util/Dictionary.h" #include "util/DictionaryRegistry.h" #include "util/FontFamilyLabel.h" @@ -78,6 +79,31 @@ inline bool settingKeyIs(const SettingInfo& setting, const char* key) { return setting.key && std::strcmp(setting.key, key) == 0; } +inline bool isSideButtonActionSetting(const SettingInfo& setting) { + return settingKeyIs(setting, "sideButtonUpShort") || settingKeyIs(setting, "sideButtonUpLong") || + settingKeyIs(setting, "sideButtonDownShort") || settingKeyIs(setting, "sideButtonDownLong"); +} + +inline std::string sideButtonOptionLabel(const SettingInfo& setting, const uint8_t displayIndex) { + if ((settingKeyIs(setting, "shortPwrBtn") || settingKeyIs(setting, "longPwrBtn")) && + displayIndex < setting.enumRawValues.size() && setting.enumRawValues[displayIndex] == CrossPointSettings::SLEEP) { + return std::string(tr(STR_SLEEP)) + "/" + tr(STR_WAKE); + } + if (isSideButtonActionSetting(setting) && displayIndex < setting.enumRawValues.size()) { + const uint8_t action = setting.enumRawValues[displayIndex]; + if (action == CrossPointSettings::SIDE_ROTATE_COUNTERCLOCKWISE || + action == CrossPointSettings::SIDE_ROTATE_CLOCKWISE) { + return std::string(tr(STR_ORIENTATION)) + " (" + + (action == CrossPointSettings::SIDE_ROTATE_CLOCKWISE ? tr(STR_DIR_RIGHT) : tr(STR_DIR_LEFT)) + ")"; + } + } + return settingEnumOptionLabel(setting, displayIndex); +} + +inline std::string sideButtonGroupLabel(const bool up) { + return std::string(up ? tr(STR_DIR_LEFT) : tr(STR_DIR_RIGHT)) + "/" + (up ? tr(STR_DIR_UP) : tr(STR_DIR_DOWN)); +} + inline SettingInfo buildFontSizeSetting(const SdCardFontRegistry* registry) { if (registry && SETTINGS.sdFontFamilyName[0] != '\0') { const SdCardFontFamilyInfo* family = registry->findFamily(SETTINGS.sdFontFamilyName); @@ -326,7 +352,7 @@ inline SettingInfo buildSleepScreenSetting() { return s; } -enum class ShortcutOptionCatalog { PowerButton, ButtonChord, LongPress, HomeButton }; +enum class ShortcutOptionCatalog { PowerButton, ButtonChord, LongPress, HomeButton, SideButton }; constexpr uint8_t SHORTCUT_OPTION_UNAVAILABLE = UINT8_MAX; @@ -337,15 +363,14 @@ inline uint8_t shortcutRawValue(const ShortcutOptionCatalog catalog, const Cross switch (catalog) { case ShortcutOptionCatalog::PowerButton: + case ShortcutOptionCatalog::SideButton: return static_cast(action); case ShortcutOptionCatalog::ButtonChord: switch (action) { case Action::IGNORE: return Chord::CHORD_DISABLED; - // Deep sleep wakes from the Power GPIO alone. A chord cannot be used - // as the matching wake gesture, so do not offer a misleading action. case Action::SLEEP: - return SHORTCUT_OPTION_UNAVAILABLE; + return Chord::CHORD_SLEEP; case Action::PAGE_TURN: return Chord::CHORD_PAGE_TURN; case Action::PREVIOUS_PAGE: @@ -473,7 +498,6 @@ inline uint8_t shortcutRawValue(const ShortcutOptionCatalog catalog, const Cross break; case ShortcutOptionCatalog::HomeButton: switch (action) { - case Action::SLEEP: case Action::TOGGLE_TILT_PAGE_TURN: case Action::TOGGLE_HOME_BUTTON_IN_READER: case Action::TOGGLE_FRONTLIGHT: @@ -487,8 +511,9 @@ inline uint8_t shortcutRawValue(const ShortcutOptionCatalog catalog, const Cross } inline void appendShortcutOptions(SettingInfo& setting, const ShortcutOptionCatalog catalog) { - setting.enumValues.reserve(QuickActions::shortcutActionOrder.size() + 3); - setting.enumRawValues.reserve(QuickActions::shortcutActionOrder.size() + 3); + const size_t extraOptions = catalog == ShortcutOptionCatalog::PowerButton ? 2 : 0; + setting.enumValues.reserve(QuickActions::shortcutActionOrder.size() + 3 + extraOptions); + setting.enumRawValues.reserve(QuickActions::shortcutActionOrder.size() + 3 + extraOptions); if (catalog == ShortcutOptionCatalog::HomeButton) { setting.enumValues.push_back(StrId::STR_BACK_HOME); @@ -507,6 +532,12 @@ inline void appendShortcutOptions(SettingInfo& setting, const ShortcutOptionCata if (rawValue == SHORTCUT_OPTION_UNAVAILABLE) continue; setting.enumValues.push_back(QuickActions::actionLabel(static_cast(action))); setting.enumRawValues.push_back(rawValue); + if (catalog == ShortcutOptionCatalog::PowerButton && action == CrossPointSettings::SLEEP) { + setting.enumValues.push_back(StrId::STR_SLEEP); + setting.enumRawValues.push_back(CrossPointSettings::SLEEP_ONLY); + setting.enumValues.push_back(StrId::STR_WAKE); + setting.enumRawValues.push_back(CrossPointSettings::WAKE_ONLY); + } } } @@ -522,6 +553,24 @@ inline SettingInfo buildHomeButtonActionSetting(const StrId nameId, uint8_t Cros return buildShortcutSetting(nameId, valuePtr, key, ShortcutOptionCatalog::HomeButton); } +inline SettingInfo buildSideButtonActionSetting(const StrId nameId, uint8_t CrossPointSettings::* const valuePtr, + const char* const key) { + SettingInfo setting = buildShortcutSetting(nameId, valuePtr, key, ShortcutOptionCatalog::SideButton); + constexpr std::pair readerActions[] = { + {CrossPointSettings::SIDE_PREVIOUS_CHAPTER, StrId::STR_PREVIOUS_CHAPTER}, + {CrossPointSettings::SIDE_NEXT_CHAPTER, StrId::STR_NEXT_CHAPTER}, + {CrossPointSettings::SIDE_INCREASE_FONT, StrId::STR_INCREASE_FONT_SIZE}, + {CrossPointSettings::SIDE_DECREASE_FONT, StrId::STR_DECREASE_FONT_SIZE}, + {CrossPointSettings::SIDE_ROTATE_COUNTERCLOCKWISE, StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION}, + {CrossPointSettings::SIDE_ROTATE_CLOCKWISE, StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION}, + }; + for (const auto& [action, label] : readerActions) { + setting.enumValues.push_back(label); + setting.enumRawValues.push_back(action); + } + return setting; +} + // Shared settings list used by both the device settings UI and the web settings API. // Each entry has a key (for JSON API) and category (for grouping). // ACTION-type entries and entries without a key are device-only. @@ -531,7 +580,7 @@ inline SettingInfo buildHomeButtonActionSetting(const StrId nameId, uint8_t Cros // can use it directly; mutable device UI lists use getSettingsList(), which // returns an owned copy and can add SD-card font and dictionary options. // Four edge gesture entries are compiled only for touch devices. -inline constexpr size_t BASE_SETTINGS_CAPACITY = 102 + (CROSSINK_APP_CAP_TOUCH ? 4 : 0); +inline constexpr size_t BASE_SETTINGS_CAPACITY = 104 + (CROSSINK_APP_CAP_TOUCH ? 4 : 0); inline const std::vector& getBaseSettingsList() { static const std::vector baseList = [] { @@ -570,16 +619,16 @@ inline const std::vector& getBaseSettingsList() { CrossPointSettings::REFRESH_30, CrossPointSettings::REFRESH_NEVER})); add(SettingInfo::Toggle(StrId::STR_NIGHT_MODE, &CrossPointSettings::screenInverted, "screenInverted", StrId::STR_CAT_DISPLAY)); - add(SettingInfo::Enum( - StrId::STR_UI_THEME, &CrossPointSettings::uiTheme, - {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_MINIMAL, StrId::STR_THEME_DASHBOARD, StrId::STR_THEME_LYRA, - StrId::STR_THEME_LYRA_EXTENDED, StrId::STR_THEME_LYRA_CAROUSEL, StrId::STR_THEME_ROUNDEDRAFF}, - "uiTheme", StrId::STR_CAT_DISPLAY) + add(SettingInfo::Enum(StrId::STR_UI_THEME, &CrossPointSettings::uiTheme, + {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_MINIMAL, StrId::STR_THEME_DASHBOARD, + StrId::STR_THEME_LYRA, StrId::STR_THEME_LYRA_EXTENDED, StrId::STR_THEME_LYRA_CAROUSEL, + StrId::STR_THEME_ROUNDEDRAFF, StrId::STR_THEME_COVER_GRID}, + "uiTheme", StrId::STR_CAT_DISPLAY) .withEnumRawValues({CrossPointSettings::UI_THEME::CLASSIC, CrossPointSettings::UI_THEME::MINIMAL, CrossPointSettings::UI_THEME::DASHBOARD, CrossPointSettings::UI_THEME::LYRA, CrossPointSettings::UI_THEME::LYRA_3_COVERS, - CrossPointSettings::UI_THEME::LYRA_CAROUSEL, - CrossPointSettings::UI_THEME::ROUNDEDRAFF})); + CrossPointSettings::UI_THEME::LYRA_CAROUSEL, CrossPointSettings::UI_THEME::ROUNDEDRAFF, + CrossPointSettings::UI_THEME::COVER_GRID})); add(SettingInfo::Enum(StrId::STR_UI_SCALE, &CrossPointSettings::uiScale, {StrId::STR_SMALL, StrId::STR_LARGE}, "uiScale", StrId::STR_CAT_DISPLAY) .withEnumRawValues({CrossPointSettings::UI_SCALE_SMALL, CrossPointSettings::UI_SCALE_LARGE})); @@ -712,20 +761,16 @@ inline const std::vector& getBaseSettingsList() { "rightEdgeDown", StrId::STR_CAT_CONTROLS) .withEnumRawValues(twoFingerSwipeActionValues)); #endif - add(SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout, - {StrId::STR_DISABLED, StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV, StrId::STR_NEXT_NEXT}, - "sideButtonLayout", StrId::STR_CAT_CONTROLS) - .withEnumRawValues({CrossPointSettings::SIDE_BUTTONS_DISABLED, CrossPointSettings::PREV_NEXT, - CrossPointSettings::NEXT_PREV, CrossPointSettings::NEXT_NEXT})); add(SettingInfo::Enum(StrId::STR_ORIENTATION_AWARE, &CrossPointSettings::sideButtonOrientationAware, {StrId::STR_NO, StrId::STR_YES}, "sideButtonOrientationAware", StrId::STR_CAT_CONTROLS)); - add(SettingInfo::Enum(StrId::STR_LONG_PRESS_ACTION, &CrossPointSettings::sideButtonLongPress, - {StrId::STR_IGNORE, StrId::STR_CHAPTER_SKIP_OPT, StrId::STR_CHANGE_FONT_SIZE, - StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION}, - "sideButtonLongPress", StrId::STR_CAT_CONTROLS) - .withEnumRawValues({CrossPointSettings::SIDE_LONG_OFF, CrossPointSettings::SIDE_LONG_CHAPTER_SKIP, - CrossPointSettings::SIDE_LONG_FONT_SIZE, - CrossPointSettings::SIDE_LONG_ORIENTATION_CHANGE})); + add(buildSideButtonActionSetting(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::sideButtonUpShort, + "sideButtonUpShort")); + add(buildSideButtonActionSetting(StrId::STR_LONG_PRESS_ACTION, &CrossPointSettings::sideButtonUpLong, + "sideButtonUpLong")); + add(buildSideButtonActionSetting(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::sideButtonDownShort, + "sideButtonDownShort")); + add(buildSideButtonActionSetting(StrId::STR_LONG_PRESS_ACTION, &CrossPointSettings::sideButtonDownLong, + "sideButtonDownLong")); add(SettingInfo::Enum(StrId::STR_ORIENTATION_AWARE, &CrossPointSettings::frontButtonOrientationAware, {StrId::STR_NO, StrId::STR_NAV_BUTTONS, StrId::STR_ALL_BUTTONS}, "frontButtonOrientationAware", StrId::STR_CAT_CONTROLS)); @@ -803,10 +848,8 @@ inline const std::vector& getBaseSettingsList() { {CrossPointSettings::MIN_READING_IDLE_TIME_THRESHOLD_UNITS, CrossPointSettings::MAX_READING_IDLE_TIME_THRESHOLD_UNITS, 1}, "readingIdleTimeThresholdUnits", StrId::STR_CAT_SYSTEM)); -#ifdef CROSSINK_ENABLE_READING_STATS_TOGGLE add(SettingInfo::Toggle(StrId::STR_TRACK_READING_STATS, &CrossPointSettings::trackReadingStats, "trackReadingStats", StrId::STR_CAT_SYSTEM)); -#endif // Frontlight quick-panel state: persisted + web-exposed, category-less so // it stays off the Settings screen (edited from the swipe-down panel). @@ -925,7 +968,7 @@ inline const std::vector& getBaseSettingsList() { // Only show tilt page turn settings when the active device has a supported IMU. if (QuickActions::supportsTiltPageTurn()) { auto shortPowerButtonIt = std::find_if( - v.begin(), v.end(), [](const SettingInfo& setting) { return setting.nameId == StrId::STR_SHORT_PWR_BTN; }); + v.begin(), v.end(), [](const SettingInfo& setting) { return settingKeyIs(setting, "shortPwrBtn"); }); if (shortPowerButtonIt != v.end()) { auto insertPos = v.insert(shortPowerButtonIt + 1, SettingInfo::Toggle(StrId::STR_TILT_PAGE_TURN, &CrossPointSettings::tiltPageTurn, @@ -946,7 +989,7 @@ inline const std::vector& getBaseSettingsList() { } } else { for (auto& setting : v) { - if (setting.nameId == StrId::STR_SHORT_PWR_BTN || settingKeyIs(setting, "longPwrBtn")) { + if (settingKeyIs(setting, "shortPwrBtn") || settingKeyIs(setting, "longPwrBtn")) { removeEnumRawValue(setting, static_cast(CrossPointSettings::TOGGLE_TILT_PAGE_TURN)); } else if (setting.nameId == StrId::STR_LONG_PRESS_MENU_ACTION || setting.nameId == StrId::STR_LONG_PRESS_BACK_ACTION) { @@ -971,6 +1014,28 @@ inline const std::vector& getBaseSettingsList() { inline std::vector getSettingsList(const SdCardFontRegistry* registry = nullptr, const DictionaryRegistry* dictRegistry = nullptr) { std::vector v = getBaseSettingsList(); + if (!SETTINGS.shouldTrackReadingStats()) { + for (auto& setting : v) { + if (setting.valuePtr == &CrossPointSettings::sleepScreen) { + removeEnumRawValue(setting, static_cast(CrossPointSettings::READING_STATS_SLEEP)); + removeEnumRawValue(setting, static_cast(CrossPointSettings::MINIMAL_STATS_SLEEP)); + } else if (setting.valuePtr == &CrossPointSettings::shortPwrBtn || + setting.valuePtr == &CrossPointSettings::longPwrBtn || + setting.valuePtr == &CrossPointSettings::homeButtonTapAction || + setting.valuePtr == &CrossPointSettings::homeButtonDoubleTapAction || + setting.valuePtr == &CrossPointSettings::homeButtonLongPressAction || + isSideButtonActionSetting(setting)) { + removeEnumRawValue(setting, CrossPointSettings::READING_STATS); + } else if (setting.valuePtr == &CrossPointSettings::powerChordAction || + setting.valuePtr == &CrossPointSettings::sideButtonChordAction) { + removeEnumRawValue(setting, + shortcutRawValue(ShortcutOptionCatalog::ButtonChord, CrossPointSettings::READING_STATS)); + } else if (setting.valuePtr == &CrossPointSettings::longPressMenuAction || + setting.valuePtr == &CrossPointSettings::longPressBackAction) { + removeEnumRawValue(setting, static_cast(CrossPointSettings::LONG_MENU_READING_STATS)); + } + } + } const bool hasTouch = gpio.hasTouch(); if (!hasTouch) { v.erase(std::remove_if(v.begin(), v.end(), @@ -1018,6 +1083,13 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist removeEnumRawValue(setting, CrossPointSettings::TWO_FINGER_SWIPE_DECREASE_WARMTH); } } + if (!UITheme::supportsCoverGrid()) { + const auto themeIt = + std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_UI_THEME; }); + if (themeIt != v.end()) { + removeEnumRawValue(*themeIt, static_cast(CrossPointSettings::UI_THEME::COVER_GRID)); + } + } if (hasTouch) { v.erase(std::remove_if(v.begin(), v.end(), [](const SettingInfo& s) { @@ -1042,7 +1114,7 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist }), v.end()); for (auto& setting : v) { - if (setting.nameId == StrId::STR_SHORT_PWR_BTN || settingKeyIs(setting, "longPwrBtn")) { + if (settingKeyIs(setting, "shortPwrBtn") || settingKeyIs(setting, "longPwrBtn")) { removeEnumRawValue(setting, CrossPointSettings::TOGGLE_HOME_BUTTON_IN_READER); } else if (settingKeyIs(setting, "powerChordAction") || settingKeyIs(setting, "sideButtonChordAction")) { removeEnumRawValue(setting, shortcutRawValue(ShortcutOptionCatalog::ButtonChord, @@ -1052,7 +1124,7 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist } if (!Frontlight.present() || !gpio.hasTouch()) { for (auto& setting : v) { - if (setting.nameId != StrId::STR_SHORT_PWR_BTN && !settingKeyIs(setting, "longPwrBtn") && + if (!settingKeyIs(setting, "shortPwrBtn") && !settingKeyIs(setting, "longPwrBtn") && !settingKeyIs(setting, "powerChordAction") && !settingKeyIs(setting, "sideButtonChordAction")) { continue; } @@ -1275,7 +1347,7 @@ inline std::vector buildControlsHomeButtonSettingsList(const std::v inline std::vector buildControlsPowerSettingsList(const std::vector& allSettings) { std::vector settings; settings.reserve(4); - addSettingByName(settings, allSettings, StrId::STR_SHORT_PWR_BTN); + addSettingByKey(settings, allSettings, "shortPwrBtn"); addSettingByKey(settings, allSettings, "longPwrBtn"); if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::FOOTNOTES || SETTINGS.longPwrBtn == CrossPointSettings::SHORT_PWRBTN::FOOTNOTES || @@ -1303,13 +1375,17 @@ inline std::vector buildControlsFrontButtonSettingsList(const std:: inline std::vector buildControlsSideButtonSettingsList(const std::vector& allSettings) { std::vector settings; const bool hasChord = hasSideButtonChordSetting(allSettings); - settings.reserve(3 + (hasChord ? 1u : 0u)); - addSettingByName(settings, allSettings, StrId::STR_SIDE_BTN_LAYOUT); + settings.reserve(8 + (hasChord ? 1u : 0u)); addSettingByKey(settings, allSettings, "sideButtonOrientationAware"); - addSettingByKey(settings, allSettings, "sideButtonLongPress"); if (hasChord) { addSettingByName(settings, allSettings, StrId::STR_SIDE_BUTTON_CHORD); } + settings.push_back(SettingInfo::SectionHeader(StrId::STR_DIR_LEFT)); + addSettingByKey(settings, allSettings, "sideButtonUpShort"); + addSettingByKey(settings, allSettings, "sideButtonUpLong"); + settings.push_back(SettingInfo::SectionHeader(StrId::STR_DIR_RIGHT)); + addSettingByKey(settings, allSettings, "sideButtonDownShort"); + addSettingByKey(settings, allSettings, "sideButtonDownLong"); return settings; } @@ -1440,6 +1516,7 @@ inline std::vector buildSystemReadingStatsSettingsList(const std::v std::vector settings; settings.reserve(3); addSettingByName(settings, allSettings, StrId::STR_TRACK_READING_STATS); + if (!SETTINGS.shouldTrackReadingStats()) return settings; settings.push_back(SettingInfo::Submenu(StrId::STR_ALL_TIME_STATS, SettingAction::SystemGlobalStats)); addSettingByName(settings, allSettings, StrId::STR_IDLE_TIME_THRESHOLD); return settings; diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 1367c57ecfb..93be0fdd3a4 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include @@ -36,6 +37,7 @@ #include "network/UsbDriveActivity.h" #include "reader/BookReadingStats.h" #include "reader/BookStatsActivity.h" +#include "reader/BookStatsTracking.h" #include "reader/GlobalReadingStats.h" #include "reader/ReaderActivity.h" #include "settings/OpdsServerListActivity.h" @@ -84,7 +86,14 @@ FrontlightPanelContext buildFrontlightPanelContext(Activity& activity, GfxRender const bool lastValid = !APP_STATE.openEpubPath.empty() && FsHelpers::hasEpubExtension(APP_STATE.openEpubPath) && Storage.exists(APP_STATE.openEpubPath.c_str()); context.activeReaderBook = hasFrontlightActiveReaderBook(activity.isReaderActivity(), currentBookValid); + context.showReadingStatsAction = SETTINGS.shouldTrackReadingStats(); if (context.activeReaderBook) { + if (FsHelpers::hasEpubExtension(currentPath)) { + context.showReadingStatsAction = + BookStatsTracking::isEnabled(Epub::cachePathForFilePath(currentPath, "/.crosspoint")); + } else if (FsHelpers::hasXtcExtension(currentPath)) { + context.showReadingStatsAction = BookStatsTracking::isEnabled(Xtc(currentPath, "/.crosspoint").getCachePath()); + } context.bookTitle = activity.getCurrentBookTitle(); context.bookPath = currentPath; context.activeEpub = activity.isEpubReaderActivity() && currentEpubValid; @@ -93,7 +102,7 @@ FrontlightPanelContext buildFrontlightPanelContext(Activity& activity, GfxRender shouldShowStickyReaderDetails(hasStickyReaderDetailsPanel(), Frontlight.present(), context.activeReaderBook); context.bookTitle = context.bookDetails.title; } - context.readingStatsActivity = activity.createFrontlightReadingStatsActivity(); + if (context.showReadingStatsAction) context.readingStatsActivity = activity.createFrontlightReadingStatsActivity(); if (context.activeEpub) { context.bookPath = currentPath; return context; @@ -101,8 +110,9 @@ FrontlightPanelContext buildFrontlightPanelContext(Activity& activity, GfxRender if (context.readingStatsActivity) return context; } - const FrontlightBookSource source = chooseFrontlightBookSource(false, false, lastValid); + const FrontlightBookSource source = chooseFrontlightBookSource(false, false, lastValid && !context.activeReaderBook); + if (!SETTINGS.shouldTrackReadingStats()) return context; const GlobalReadingStats global = GlobalReadingStats::load(); std::string cachePath; std::string statsTitle; @@ -113,7 +123,12 @@ FrontlightPanelContext buildFrontlightPanelContext(Activity& activity, GfxRender context.bookTitle = fileNameFromPath(context.bookPath); statsTitle = context.bookTitle; cachePath = Epub::cachePathForFilePath(context.bookPath, "/.crosspoint"); - bookStats = BookReadingStats::load(cachePath); + if (BookStatsTracking::isBookEnabled(cachePath)) + bookStats = BookReadingStats::load(cachePath); + else { + cachePath.clear(); + statsTitle = tr(STR_READING_STATS); + } const RecentBook book{context.bookPath, context.bookTitle, {}, {}}; progress = RecentBookProgress::loadCachedEpubPercent(book); } else { @@ -970,6 +985,7 @@ bool ActivityManager::resumeFileTransferFromNetworkBoot(const uint32_t payload) } void ActivityManager::goToNearbyStatsSync() { + if (!SETTINGS.shouldTrackReadingStats()) return; replaceActivity(std::make_unique(renderer, mappedInput)); } diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index a852a6f9e81..809e956ff23 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -23,6 +23,7 @@ #include #include "../home/RecentBookProgress.h" +#include "../reader/BookStatsTracking.h" #include "../reader/BookStatsView.h" #include "../reader/EpubReaderActivity.h" #include "../reader/EpubReaderUtils.h" @@ -330,7 +331,15 @@ BookReadingStats loadBookStatsForPath(const std::string& path) { if (cachePath.empty()) { return BookReadingStats{}; } - return BookReadingStats::load(cachePath); + BookReadingStats stats = BookReadingStats::load(cachePath); + if (!BookStatsTracking::isEnabled(cachePath)) { + BookReadingStats paceOnly; + paceOnly.avgSecondsPerForwardPage = stats.avgSecondsPerForwardPage; + paceOnly.paceSampleCount = stats.paceSampleCount; + paceOnly.estimatedTimeLeftSeconds = stats.estimatedTimeLeftSeconds; + return paceOnly; + } + return stats; } std::string loadChapterTitleForPath(const std::string& path) { @@ -625,10 +634,12 @@ void SleepActivity::onEnter() { case (CrossPointSettings::SLEEP_SCREEN_MODE::OVERLAY): return renderOverlaySleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::READING_STATS_SLEEP): + if (!BookStatsTracking::isEnabled(bookStatsCachePathFor(recentBookPath))) return renderMinimalSleepScreen(); return renderReadingStatsSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::MINIMAL_SLEEP): return renderMinimalSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::MINIMAL_STATS_SLEEP): + if (!BookStatsTracking::isEnabled(bookStatsCachePathFor(recentBookPath))) return renderMinimalSleepScreen(); return renderMinimalStatsSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::DASHBOARD_SLEEP): return renderDashboardSleepScreen(); @@ -988,7 +999,9 @@ void SleepActivity::renderMinimalSleepScreen() const { const BookReadingStats bookStats = loadBookStatsForPath(path); const float progressPercent = RecentBookProgress::loadPercent(book); MinimalTheme theme; - theme.drawSleepScreen(renderer, book, &bookStats, progressPercent, sleepCoverFilterInvertsGeneratedScreen()); + theme.drawSleepScreen(renderer, book, + BookStatsTracking::isEnabled(bookStatsCachePathFor(path)) ? &bookStats : nullptr, + progressPercent, sleepCoverFilterInvertsGeneratedScreen()); renderer.displayBuffer(HalDisplay::HALF_REFRESH, TURN_OFF_SCREEN_AFTER_SLEEP_REFRESH); } @@ -1046,12 +1059,12 @@ void SleepActivity::renderLastScreenSleepScreen() const { } else { renderer.drawImage(MoonIcon, 0, pageHeight - MOONICON_HEIGHT, MOONICON_WIDTH, MOONICON_HEIGHT); } + // Only the moon differs from the displayed frame, so a differential FAST + // update avoids the flashing clean pass on an inverted night-mode page. if (gpio.deviceIsX3()) { - // The controller still holds the displayed page, so its differential base - // waveform can add the moon without a full-screen flash. renderer.displayGrayscaleBase(HalDisplay::FAST_REFRESH); } else { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); } } diff --git a/src/activities/home/BookActions.cpp b/src/activities/home/BookActions.cpp index b8684ba31e6..599e26ac380 100644 --- a/src/activities/home/BookActions.cpp +++ b/src/activities/home/BookActions.cpp @@ -20,6 +20,7 @@ #include "RecentBooksStore.h" #include "activities/reader/BookReadingStats.h" #include "activities/reader/BookStatsActivity.h" +#include "activities/reader/BookStatsTracking.h" #include "activities/reader/EpubReaderActivity.h" #include "activities/reader/GlobalReadingStats.h" #include "components/UITheme.h" @@ -49,7 +50,7 @@ std::string bookStatsCachePath(const std::string& path) { std::vector buildBookActionItems(const std::string& fullPath, const bool includeRemoveFromRecents) { std::vector items; - items.reserve(includeRemoveFromRecents ? 8 : 7); + items.reserve(includeRemoveFromRecents ? 9 : 8); items.push_back({FileBrowserAction::Delete, StrId::STR_DELETE}); if (hasClearableBookCache(fullPath)) { items.push_back({FileBrowserAction::DeleteCache, StrId::STR_DELETE_CACHE}); @@ -59,8 +60,15 @@ std::vector buildBookActionItems(const std: items.push_back({FileBrowserAction::ResetReaderSettings, StrId::STR_RESET_BOOK_READER_SETTINGS}); } if (hasReadingStats(fullPath)) { - items.push_back({FileBrowserAction::ReadingStats, StrId::STR_READING_STATS}); - items.push_back({FileBrowserAction::DeleteStats, StrId::STR_DELETE_BOOK_STATS}); + if (SETTINGS.shouldTrackReadingStats()) { + const bool bookEnabled = isBookStatsTrackingEnabled(fullPath); + items.push_back({FileBrowserAction::ToggleBookStatsTracking, StrId::STR_TRACK_READING_STATS, + bookEnabled ? StrId::STR_STATE_ON : StrId::STR_STATE_OFF}); + if (bookEnabled) { + items.push_back({FileBrowserAction::ReadingStats, StrId::STR_READING_STATS}); + items.push_back({FileBrowserAction::DeleteStats, StrId::STR_DELETE_BOOK_STATS}); + } + } items.push_back({FileBrowserAction::ToggleCompleted, isBookCompleted(fullPath) ? StrId::STR_MARK_UNFINISHED : StrId::STR_MARK_FINISHED}); } @@ -109,7 +117,7 @@ bool deleteBookStats(const std::string& fullPath) { std::unique_ptr createReadingStatsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& fullPath, const std::string& title) { const std::string cachePath = bookStatsCachePath(fullPath); - if (cachePath.empty()) { + if (cachePath.empty() || !BookStatsTracking::isEnabled(cachePath)) { LOG_ERR("BookActions", "No reading stats for: %s", fullPath.c_str()); return {}; } @@ -176,6 +184,19 @@ bool isBookCompleted(const std::string& fullPath) { return !cachePath.empty() && BookReadingStats::load(cachePath).isCompleted; } +bool isBookStatsTrackingEnabled(const std::string& fullPath) { + return BookStatsTracking::isBookEnabled(bookStatsCachePath(fullPath)); +} + +bool toggleBookStatsTracking(const std::string& fullPath, bool& enabled) { + if (!hasReadingStats(fullPath) || !SETTINGS.shouldTrackReadingStats()) return false; + const std::string cachePath = bookStatsCachePath(fullPath); + enabled = !BookStatsTracking::isBookEnabled(cachePath); + if (BookStatsTracking::setBookEnabled(cachePath, enabled)) return true; + enabled = BookStatsTracking::isBookEnabled(cachePath); + return false; +} + bool toggleBookCompleted(const std::string& fullPath, const std::string& displayName, bool& completed) { const bool isEpub = FsHelpers::hasEpubExtension(fullPath); const bool isXtc = FsHelpers::hasXtcExtension(fullPath); @@ -206,25 +227,27 @@ bool toggleBookCompleted(const std::string& fullPath, const std::string& display BookReadingStats stats = BookReadingStats::load(cachePath); completed = !stats.isCompleted; stats.isCompleted = completed; - if (completed && !stats.finishedDateManual) { + const bool trackStats = BookStatsTracking::isEnabled(cachePath); + if (completed && trackStats && !stats.finishedDateManual) { ReadingStatsDateTime now; if (getCurrentLocalReadingStatsDateTime(now)) { stats.finishedDate = now.date; } } - GlobalReadingStats globalStats = GlobalReadingStats::load(); - if (completed) { - globalStats.completedBooks++; - } else if (globalStats.completedBooks > 0) { - globalStats.completedBooks--; - } - if (!stats.save(cachePath)) { LOG_ERR("BookActions", "Could not save completion for: %s", fullPath.c_str()); return false; } - globalStats.save(); + if (trackStats) { + GlobalReadingStats globalStats = GlobalReadingStats::load(); + if (completed) { + globalStats.completedBooks++; + } else if (globalStats.completedBooks > 0) { + globalStats.completedBooks--; + } + globalStats.save(); + } // Changing completion status does not open a book. The reader adds it to // recents if it is opened again after being marked unfinished. diff --git a/src/activities/home/BookActions.h b/src/activities/home/BookActions.h index 5a657e128c6..84dbf00cc36 100644 --- a/src/activities/home/BookActions.h +++ b/src/activities/home/BookActions.h @@ -27,6 +27,8 @@ uint8_t epubRenderModeDisplayIndex(uint8_t renderMode); uint8_t epubRenderModeForDisplayIndex(uint8_t displayIndex); std::string confirmationHeading(StrId actionLabelId); bool isBookCompleted(const std::string& fullPath); +bool isBookStatsTrackingEnabled(const std::string& fullPath); +bool toggleBookStatsTracking(const std::string& fullPath, bool& enabled); bool toggleBookCompleted(const std::string& fullPath, const std::string& displayName, bool& completed); void drawToast(const GfxRenderer& renderer, const char* msg); diff --git a/src/activities/home/FileBrowserActionActivity.cpp b/src/activities/home/FileBrowserActionActivity.cpp index 93902d34079..25c6c7b8fb5 100644 --- a/src/activities/home/FileBrowserActionActivity.cpp +++ b/src/activities/home/FileBrowserActionActivity.cpp @@ -28,8 +28,14 @@ void FileBrowserActionActivity::onEnter() { ignoreTouchRelease = mappedInput.isScreenTouchHeld(touchX, touchY); } optionLabels.resize(items.size()); - std::transform(items.begin(), items.end(), optionLabels.begin(), - [](const MenuItem& item) { return std::string(I18N.get(item.labelId)); }); + std::transform(items.begin(), items.end(), optionLabels.begin(), [](const MenuItem& item) { + std::string label = I18N.get(item.labelId); + if (item.valueId) { + label += ": "; + label += I18N.get(*item.valueId); + } + return label; + }); optionPopup.show(title.c_str(), optionLabels, 0, [this](const int index) { if (index < 0 || index >= static_cast(items.size())) return; selectionMade = true; diff --git a/src/activities/home/FileBrowserActionActivity.h b/src/activities/home/FileBrowserActionActivity.h index 0247dd32d3a..bae21e086a4 100644 --- a/src/activities/home/FileBrowserActionActivity.h +++ b/src/activities/home/FileBrowserActionActivity.h @@ -2,6 +2,7 @@ #include +#include #include #include @@ -29,6 +30,7 @@ enum class FileBrowserAction : int { UnpinBootFavorite = 17, Rename = 18, ReadingStats = 19, + ToggleBookStatsTracking = 20, }; class FileBrowserActionActivity final : public Activity { @@ -36,6 +38,7 @@ class FileBrowserActionActivity final : public Activity { struct MenuItem { FileBrowserAction action; StrId labelId; + std::optional valueId; }; FileBrowserActionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title, diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 92a47ed1058..940485b1e71 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -551,6 +551,7 @@ void FileBrowserActivity::showDirectoryActionMenu(const std::string& entry, bool clearPreferredSleepFolder(); return; case FileBrowserAction::DeleteCache: + case FileBrowserAction::ToggleBookStatsTracking: case FileBrowserAction::ReadingStats: case FileBrowserAction::DeleteStats: case FileBrowserAction::ToggleCompleted: @@ -711,6 +712,15 @@ void FileBrowserActivity::showFileActionMenu(const std::string& entry, bool igno const auto action = static_cast(std::get(result.data).action); switch (action) { + case FileBrowserAction::ToggleBookStatsTracking: { + bool enabled = false; + if (!BookActions::toggleBookStatsTracking(fullPath, enabled)) { + const std::string error = std::string(tr(STR_TRACK_READING_STATS)) + " " + tr(STR_FAILED_LOWER); + BookActions::drawToast(renderer, error.c_str()); + } + requestUpdate(); + return; + } case FileBrowserAction::ReadingStats: if (auto statsActivity = BookActions::createReadingStatsActivity(renderer, mappedInput, fullPath, getFileName(entry))) { diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 2d79e834616..7efafaf79fe 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -24,6 +26,7 @@ #include "../reader/BookReadingStats.h" #include "../reader/BookStatsActivity.h" +#include "../reader/BookStatsTracking.h" #include "../reader/EpubReaderUtils.h" #include "BookmarkStore.h" #include "ClippingStore.h" @@ -170,13 +173,24 @@ std::string getRecentBookCachePath(const RecentBook& book) { return ""; } +BookReadingStats visibleRecentBookStats(const RecentBook& book, BookReadingStats stats) { + const std::string cachePath = getRecentBookCachePath(book); + if (!BookStatsTracking::isEnabled(cachePath)) { + BookReadingStats paceOnly; + paceOnly.avgSecondsPerForwardPage = stats.avgSecondsPerForwardPage; + paceOnly.paceSampleCount = stats.paceSampleCount; + paceOnly.estimatedTimeLeftSeconds = stats.estimatedTimeLeftSeconds; + return paceOnly; + } + return stats; +} + BookReadingStats loadRecentBookStats(const RecentBook& book) { if (!FsHelpers::hasEpubExtension(book.path) && !FsHelpers::hasXtcExtension(book.path)) { return BookReadingStats{}; } - const std::string cachePath = getRecentBookCachePath(book); - return BookReadingStats::load(cachePath); + return visibleRecentBookStats(book, BookReadingStats::load(getRecentBookCachePath(book))); } float loadRecentBookProgress(const RecentBook& book) { @@ -421,6 +435,7 @@ void appendCarouselCoverStateToKey(std::string& key, const RecentBook& book) { if (FsHelpers::hasEpubExtension(book.path)) appendHashedFileStateToKey(key, cachePath + "/progress.bin.bak"); if (FsHelpers::hasEpubExtension(book.path) || FsHelpers::hasXtcExtension(book.path)) { appendHashedFileStateToKey(key, cachePath + "/stats_v5.bin"); + appendHashedFileStateToKey(key, cachePath + "/reading_stats_off"); } } else { key += "no-cache-path"; @@ -481,6 +496,8 @@ void buildCarouselCacheKey(const std::vector& recentBooks, const boo // versa). key += SETTINGS.screenInverted ? "dark:1" : "dark:0"; key += '\0'; + key += SETTINGS.shouldTrackReadingStats() ? "tracking:1" : "tracking:0"; + key += '\0'; // The carousel cache stores the bottom icon row too, so menu visibility must // be part of the key alongside book covers/progress. appendCarouselMenuStateToKey(key, hasOpdsServers, hasReadingStats, hasBookmarks, hasClippings); @@ -591,6 +608,7 @@ static_assert(HomeActivity::kMaxCachedBooks >= LyraCarouselMetrics::values.homeR "kMaxCachedBooks must cover all carousel slots"); int HomeActivity::getMenuItemCount() const { + if (coverGridUi) return static_cast(recentBooks.size()) + (hasOpdsServers ? 5 : 4); const auto& metrics = UITheme::getInstance().getMetrics(); int count = 4; // File Browser, Library, File transfer, Settings if (!metrics.homeContinueReadingInMenu && !recentBooks.empty()) { @@ -613,7 +631,7 @@ int HomeActivity::getMenuItemCount() const { void HomeActivity::loadRecentBooks(int maxBooks) { recentBooks.clear(); const auto& books = RECENT_BOOKS.getBooks(); - recentBooks.reserve(std::min(static_cast(books.size()), maxBooks)); + recentBooks.reserve(coverGridUi ? maxBooks : std::min(static_cast(books.size()), maxBooks)); for (const RecentBook& storedBook : books) { // Limit to maximum number of recent books @@ -631,6 +649,104 @@ void HomeActivity::loadRecentBooks(int maxBooks) { } } +void HomeActivity::fillCoverGridFromLibrary() { + if (recentBooks.size() >= CoverGridHomeUi::MAX_BOOKS) return; + + struct LibraryReader { + library::LibraryIndexFile index; + library::ClixRecord record; + }; + auto reader = makeUniqueNoThrow(); + if (!reader) { + LOG_ERR("HOME", "Cannot allocate library index reader for cover grid"); + return; + } + auto& index = reader->index; + const bool indexOpen = index.open(library::libraryIndexPath()); + const bool needsRefresh = library::libraryIndexNeedsRefresh() || !indexOpen || + index.header().metadataEnabled != static_cast(SETTINGS.libraryUseMetadata != 0); + if (needsRefresh) { + index.close(); + // Home has not painted yet. Give the same visible scan feedback as Library + // before rebuilding an index for a large SD card. + { + RenderLock lock; + renderer.clearScreen(); + GUI.drawPopup(renderer, tr(STR_LIBRARY_SCANNING)); + renderer.displayBuffer(initialRefreshMode); + initialRefreshMode = HalDisplay::FAST_REFRESH; + } + library::BuildStats stats; + if (!library::buildLibraryIndex("/", stats, SETTINGS.libraryUseMetadata != 0) || + !index.open(library::libraryIndexPath())) { + LOG_ERR("HOME", "Cannot populate cover grid from library index"); + return; + } + } + + for (uint16_t row = 0; row < index.bookCount() && recentBooks.size() < CoverGridHomeUi::MAX_BOOKS; ++row) { + RecentBook book; + const uint16_t ordinal = index.ordinalForRow(library::SortOrder::RecentDesc, row); + if (ordinal == 0xffff || !index.readRecord(ordinal, reader->record) || !index.readPath(reader->record, book.path)) { + continue; + } + if (std::any_of(recentBooks.begin(), recentBooks.end(), + [&book](const RecentBook& existing) { return existing.path == book.path; }) || + RecentBooksStore::isMissing(book)) { + continue; + } + if (!index.readDisplayText(reader->record, book.title, book.author)) continue; + recentBooks.push_back(std::move(book)); + } +} + +void HomeActivity::loadCoverGridThumbnails() { + recentsLoading = true; + bool showingLoading = false; + Rect popupRect; + for (size_t i = 0; i < recentBooks.size(); ++i) { + auto& book = recentBooks[i]; + if (book.coverState == RecentBook::CoverState::Missing || !Storage.exists(book.path.c_str())) continue; + const int height = coverGridUi->thumbHeightFor(i); + if (book.coverBmpPath.empty()) { + if (FsHelpers::hasEpubExtension(book.path)) { + auto epub = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (epub) book.coverBmpPath = epub->getThumbBmpPath(); + } else if (FsHelpers::hasXtcExtension(book.path)) { + auto xtc = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (xtc) book.coverBmpPath = xtc->getThumbBmpPath(); + } + } + const std::string thumbPath = UITheme::getCoverThumbPath(book.coverBmpPath, height); + if (thumbPath.empty() || Storage.exists(thumbPath.c_str())) continue; + if (!showingLoading) { + showingLoading = true; + popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + } + GUI.fillPopupProgress(renderer, popupRect, static_cast(100 * i / std::max(1, recentBooks.size()))); + renderer.displayBuffer(); + if (FsHelpers::hasEpubExtension(book.path)) { + auto epub = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (!epub) { + LOG_ERR("HOME", "Cannot allocate EPUB for cover grid thumbnail"); + continue; + } + if (!epub->generateThumbBmpFromSource(height, &renderer, SETTINGS.getReaderFontId())) { + LOG_ERR("HOME", "Cannot create cover grid thumbnail: %s", book.path.c_str()); + } + } else if (FsHelpers::hasXtcExtension(book.path)) { + auto xtc = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (!xtc) { + LOG_ERR("HOME", "Cannot allocate XTC for cover grid thumbnail"); + continue; + } + if (xtc->load()) xtc->generateThumbBmp(height); + } + } + recentsLoaded = true; + recentsLoading = false; +} + void HomeActivity::loadAllBookStats() { const auto start = millis(); const int count = std::min(static_cast(recentBooks.size()), kMaxCachedBooks); @@ -849,6 +965,10 @@ void HomeActivity::onEnter() { Activity::onEnter(); hasOpdsServers = OPDS_STORE.hasServers(); + if (UITheme::hasCoverGridHome()) { + coverGridUi = makeUniqueNoThrow(renderer); + if (!coverGridUi) LOG_ERR("HOME", "Cannot allocate cover grid UI; using standard Home"); + } const bool isCarouselTheme = static_cast(SETTINGS.uiTheme) == CrossPointSettings::UI_THEME::LYRA_CAROUSEL; @@ -875,9 +995,12 @@ void HomeActivity::onEnter() { const auto& metrics = UITheme::getInstance().getMetrics(); const int recentBooksToLoad = - std::min(kMaxCachedBooks, std::max(metrics.homeRecentBooksCount, HOME_BOOK_SWAP_RECENT_COUNT)); + coverGridUi ? CoverGridHomeUi::MAX_BOOKS + : std::min(kMaxCachedBooks, std::max(metrics.homeRecentBooksCount, HOME_BOOK_SWAP_RECENT_COUNT)); RECENT_BOOKS.ensureLoaded(); loadRecentBooks(recentBooksToLoad); + gridHasContinueReading = !recentBooks.empty(); + if (coverGridUi) fillCoverGridFromLibrary(); const auto selectInitialBook = [this, &metrics](const std::string& path) { if (path.empty()) { @@ -886,7 +1009,7 @@ void HomeActivity::onEnter() { for (int i = 0; i < static_cast(recentBooks.size()); ++i) { if (recentBooks[i].path == path) { - if (metrics.homeRecentBooksCount == 1 && i > 0) { + if (metrics.homeRecentBooksCount == 1 && i > 0 && !coverGridUi) { std::rotate(recentBooks.begin(), recentBooks.begin() + i, recentBooks.end()); selectorIndex = 0; lastCarouselBookIndex = 0; @@ -912,7 +1035,30 @@ void HomeActivity::onEnter() { } updateHighlightedBookContext(false); - if (initialMenuItem != HomeMenuItem::NONE) { + if (coverGridUi) { + const int base = static_cast(recentBooks.size()); + switch (initialMenuItem) { + case HomeMenuItem::FILE_BROWSER: + selectorIndex = base; + break; + case HomeMenuItem::LIBRARY: + selectorIndex = base + 1; + break; + case HomeMenuItem::OPDS_BROWSER: + selectorIndex = base + 2; + break; + case HomeMenuItem::FILE_TRANSFER: + selectorIndex = base + (hasOpdsServers ? 3 : 2); + break; + case HomeMenuItem::SETTINGS_MENU: + selectorIndex = base + (hasOpdsServers ? 4 : 3); + break; + case HomeMenuItem::NONE: + break; + } + coverGridUi->begin(recentBooks, hasOpdsServers, gridHasContinueReading, + gridHasContinueReading ? loadRecentBookProgress(recentBooks.front()) : -1.0f); + } else if (initialMenuItem != HomeMenuItem::NONE) { const bool includeContinueReading = metrics.homeContinueReadingInMenu && !recentBooks.empty(); const auto menuItems = buildSelectableHomeMenuItems(hasOpdsServers, hasReadingStats, hasBookmarks, hasClippings, includeContinueReading); @@ -935,15 +1081,21 @@ int HomeActivity::getHighlightedBookIndex() const { return -1; } + if (coverGridUi) { + return selectorIndex < static_cast(recentBooks.size()) ? selectorIndex : 0; + } + const int visibleBookCount = getVisibleRecentBookCount(); const int highlightedBookIdx = (selectorIndex < visibleBookCount) ? selectorIndex : lastCarouselBookIndex; return std::clamp(highlightedBookIdx, 0, visibleBookCount - 1); } -int HomeActivity::getVisibleRecentBookCount() const { return ::getVisibleRecentBookCount(recentBooks); } +int HomeActivity::getVisibleRecentBookCount() const { + return coverGridUi ? static_cast(recentBooks.size()) : ::getVisibleRecentBookCount(recentBooks); +} bool HomeActivity::canSwapHomeBook() const { - return UITheme::getInstance().getMetrics().homeRecentBooksCount == 1 && recentBooks.size() > 1; + return !coverGridUi && UITheme::getInstance().getMetrics().homeRecentBooksCount == 1 && recentBooks.size() > 1; } void HomeActivity::showNextRecentBookOnHome() { @@ -987,14 +1139,21 @@ std::unique_ptr HomeActivity::createFrontlightReadingStatsActivity() { } } const std::string cachePath = validEpub ? Epub::cachePathForFilePath(path, "/.crosspoint") : std::string{}; - const BookReadingStats bookStats = validEpub ? BookReadingStats::load(cachePath) : BookReadingStats{}; + const bool showBookStats = validEpub && BookStatsTracking::isEnabled(cachePath); + const BookReadingStats bookStats = showBookStats ? BookReadingStats::load(cachePath) : BookReadingStats{}; + if (!SETTINGS.shouldTrackReadingStats()) return {}; + if (!showBookStats) { + title = tr(STR_READING_STATS); + progress = -1.0f; + } const GlobalReadingStats deviceStats = GlobalReadingStats::load(); if (GlobalReadingStats::hasSyncedStats()) { - return makeUniqueNoThrow(renderer, mappedInput, title, cachePath, bookStats, progress, false, 0, - deviceStats, GlobalReadingStats::loadAggregated(deviceStats)); + return makeUniqueNoThrow(renderer, mappedInput, title, showBookStats ? cachePath : std::string{}, + bookStats, progress, false, 0, deviceStats, + GlobalReadingStats::loadAggregated(deviceStats)); } - return makeUniqueNoThrow(renderer, mappedInput, title, cachePath, bookStats, progress, false, 0, - deviceStats); + return makeUniqueNoThrow(renderer, mappedInput, title, showBookStats ? cachePath : std::string{}, + bookStats, progress, false, 0, deviceStats); } void HomeActivity::onFrontlightPanelClosed() { @@ -1047,7 +1206,7 @@ void HomeActivity::updateHighlightedBookContext(const bool allowChapterTitleRead const bool isEpub = FsHelpers::hasEpubExtension(book.path); const bool loadChapterTitle = isDashboardTheme(); if (useCachedStats) { - currentBookStats = cachedBookStats[idx]; + currentBookStats = visibleRecentBookStats(book, cachedBookStats[idx]); currentBookProgressPercent = cachedBookProgress[idx]; if (allowChapterTitleRead && loadChapterTitle && isEpub) { currentBookChapterTitle = loadEpubHighlightedChapterTitle(book); @@ -1061,8 +1220,9 @@ void HomeActivity::updateHighlightedBookContext(const bool allowChapterTitleRead } } - hasReadingStats = hasAnyBookStats(currentBookStats) || hasAnyGlobalStats(globalStats) || - (showAllDevicesStats && hasAnyGlobalStats(allDevicesGlobalStats)); + hasReadingStats = + SETTINGS.shouldTrackReadingStats() && (hasAnyBookStats(currentBookStats) || hasAnyGlobalStats(globalStats) || + (showAllDevicesStats && hasAnyGlobalStats(allDevicesGlobalStats))); } void HomeActivity::onExit() { @@ -1072,6 +1232,7 @@ void HomeActivity::onExit() { LibraryPrewarm::stop(libraryPrewarmHandOff); libraryPrewarmHandOff = false; + coverGridUi.reset(); carouselMenuTouchDownIndex = -1; freeCoverBuffer(); gCarouselCache.invalidate(); @@ -1210,7 +1371,7 @@ void HomeActivity::renderCarouselFrameToCurrentBuffer(int bookIdx, BookReadingSt if (bookIdx >= 0 && bookIdx < bookCount) { if (bookStatsCached && bookIdx < kMaxCachedBooks) { usedCachedStats = true; - frameStats = cachedBookStats[bookIdx]; + frameStats = visibleRecentBookStats(recentBooks[bookIdx], cachedBookStats[bookIdx]); frameProgressPercent = cachedBookProgress[bookIdx]; } else { frameStats = loadRecentBookStats(recentBooks[bookIdx]); @@ -1226,8 +1387,9 @@ void HomeActivity::renderCarouselFrameToCurrentBuffer(int bookIdx, BookReadingSt renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks, bookCount, dummy1, dummy2, dummy3, []() { return true; }, frameStatsPtr, frameProgressPercent); - const bool frameHasReadingStats = hasAnyBookStats(frameStats) || hasAnyGlobalStats(globalStats) || - (showAllDevicesStats && hasAnyGlobalStats(allDevicesGlobalStats)); + const bool frameHasReadingStats = + SETTINGS.shouldTrackReadingStats() && (hasAnyBookStats(frameStats) || hasAnyGlobalStats(globalStats) || + (showAllDevicesStats && hasAnyGlobalStats(allDevicesGlobalStats))); const auto menuItems = buildHomeMenuItems(hasOpdsServers, frameHasReadingStats, hasBookmarks, hasClippings); GUI.drawButtonMenu( renderer, @@ -1530,6 +1692,42 @@ void HomeActivity::loop() { if (quickActionsPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; + if (coverGridUi) { + const int touched = coverGridUi->selectedAction(mappedInput); + if (coverGridUi->app.invalidated()) requestUpdate(); + if (touched >= 0 && touched < getMenuItemCount()) { + selectorIndex = touched; + activateCoverGridSelection(); + return; + } + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) backPressSeen = true; + if (mappedInput.wasReleased(MappedInputManager::Button::Back) && backPressSeen && gridHasContinueReading) { + onSelectBook(recentBooks.front().path); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + activateCoverGridSelection(); + return; + } + + const int bookCount = static_cast(recentBooks.size()); + const int tabCount = hasOpdsServers ? 5 : 4; + const auto cycleBand = [this](const int base, const int count, const int dir) { + if (count <= 0) return; + const int current = selectorIndex - base; + selectorIndex = + base + (current < 0 || current >= count ? (dir > 0 ? 0 : count - 1) : (current + count + dir) % count); + requestUpdate(); + }; + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [&] { cycleBand(0, bookCount, -1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [&] { cycleBand(0, bookCount, 1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, + [&] { cycleBand(bookCount, tabCount, -1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, + [&] { cycleBand(bookCount, tabCount, 1); }); + return; + } + if (usesMinimalHomeInteraction()) { const int pressedFrontButton = mappedInput.getPressedFrontButton(); const int releasedFrontButton = mappedInput.getReleasedFrontButton(); @@ -2084,6 +2282,40 @@ void HomeActivity::loop() { } } +void HomeActivity::activateCoverGridSelection() { + if (selectorIndex < 0) return; + if (selectorIndex < static_cast(recentBooks.size())) { + onSelectBook(recentBooks[selectorIndex].path); + return; + } + const int tab = selectorIndex - static_cast(recentBooks.size()); + switch (tab) { + case 0: + onFileBrowserOpen(); + break; + case 1: + onLibraryOpen(); + break; + case 2: + if (hasOpdsServers) { + onOpdsBrowserOpen(); + } else { + onFileTransferOpen(); + } + break; + case 3: + if (hasOpdsServers) { + onFileTransferOpen(); + } else { + onSettingsOpen(); + } + break; + case 4: + if (hasOpdsServers) onSettingsOpen(); + break; + } +} + bool HomeActivity::handleShortcutAction(const CrossPointSettings::SHORT_PWRBTN action) { if (action == CrossPointSettings::SHORT_PWRBTN::FILE_BROWSER) { onFileBrowserOpen(); @@ -2147,6 +2379,30 @@ void HomeActivity::render(RenderLock&&) { initialRefreshMode = HalDisplay::FAST_REFRESH; }; + if (coverGridUi) { + renderer.clearScreen(); + coverGridUi->setSelection(selectorIndex); + coverGridUi->render(); + const auto labels = mappedInput.mapLabels(gridHasContinueReading ? tr(STR_READ) : "", tr(STR_SELECT), + tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + displayHomeBuffer(); + + if (coverGridUi->takeThumbHeightsChanged()) { + coverGridUi->refreshCoverPaths(); + recentsLoaded = false; + } + if (!firstRenderDone) { + firstRenderDone = true; + requestUpdate(); + } else if (!recentsLoaded && !recentsLoading) { + loadCoverGridThumbnails(); + coverGridUi->refreshCoverPaths(); + requestUpdate(); + } + return; + } + if (usesMinimalHomeInteraction()) { renderer.clearScreen(); @@ -2178,8 +2434,8 @@ void HomeActivity::render(RenderLock&&) { GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored, std::bind(&HomeActivity::storeCoverBuffer, this), - hasAnyBookStats(currentBookStats) ? ¤tBookStats : nullptr, currentBookProgressPercent, - &globalStats, currentBookChapterTitle.c_str()); + (isDashboardTheme() || hasAnyBookStats(currentBookStats)) ? ¤tBookStats : nullptr, + currentBookProgressPercent, &globalStats, currentBookChapterTitle.c_str()); const int homeNavCount = minimalHomeNavCount(!recentBooks.empty()); if (minimalHomeNavIndex >= homeNavCount) { @@ -2403,18 +2659,42 @@ void HomeActivity::onFileTransferOpen() { activityManager.goToFileTransfer(); } void HomeActivity::onOpdsBrowserOpen() { activityManager.goToBrowser(); } void HomeActivity::onReadingStatsOpen() { + if (!SETTINGS.shouldTrackReadingStats()) return; // Pushed screens stop Home's loop; don't leave a paused build holding heap. LibraryPrewarm::stop(false); const int highlightedBookIdx = getHighlightedBookIndex(); - const std::string bookTitle = + std::string bookTitle = highlightedBookIdx >= 0 ? recentBooks[highlightedBookIdx].title : std::string(tr(STR_READING_STATS)); - const std::string bookPath = getCurrentBookPath(); - const std::string cachePath = - FsHelpers::hasEpubExtension(bookPath) ? Epub::cachePathForFilePath(bookPath, "/.crosspoint") : std::string{}; + const bool hasBookStats = + highlightedBookIdx >= 0 && (FsHelpers::hasEpubExtension(recentBooks[highlightedBookIdx].path) || + FsHelpers::hasXtcExtension(recentBooks[highlightedBookIdx].path)); + std::string cachePath = hasBookStats ? getRecentBookCachePath(recentBooks[highlightedBookIdx]) : std::string{}; + const bool showBookStats = !cachePath.empty() && BookStatsTracking::isBookEnabled(cachePath); + if (!showBookStats) { + cachePath.clear(); + bookTitle = tr(STR_READING_STATS); + } + const BookReadingStats displayBookStats = showBookStats ? currentBookStats : BookReadingStats{}; + const float progress = showBookStats ? currentBookProgressPercent : -1.0f; if (showAllDevicesStats) { + startActivityForResult( + std::make_unique(renderer, mappedInput, bookTitle, cachePath, displayBookStats, progress, + false, 0, globalStats, allDevicesGlobalStats, true), + [this](const ActivityResult& result) { + mappedInput.suppressNextConfirmRelease(); + const auto* statsResult = std::get_if(&result.data); + if (statsResult && statsResult->changed) { + globalStats = GlobalReadingStats::load(); + showAllDevicesStats = GlobalReadingStats::hasSyncedStats(); + allDevicesGlobalStats = showAllDevicesStats ? GlobalReadingStats::loadAggregated(globalStats) : globalStats; + bookStatsCached = false; + updateHighlightedBookContext(); + } + requestUpdate(); + }); + } else { startActivityForResult(std::make_unique(renderer, mappedInput, bookTitle, cachePath, - currentBookStats, currentBookProgressPercent, false, 0, - globalStats, allDevicesGlobalStats, true), + displayBookStats, progress, false, 0, globalStats, true), [this](const ActivityResult& result) { mappedInput.suppressNextConfirmRelease(); const auto* statsResult = std::get_if(&result.data); @@ -2428,22 +2708,6 @@ void HomeActivity::onReadingStatsOpen() { } requestUpdate(); }); - } else { - startActivityForResult( - std::make_unique(renderer, mappedInput, bookTitle, cachePath, currentBookStats, - currentBookProgressPercent, false, 0, globalStats, true), - [this](const ActivityResult& result) { - mappedInput.suppressNextConfirmRelease(); - const auto* statsResult = std::get_if(&result.data); - if (statsResult && statsResult->changed) { - globalStats = GlobalReadingStats::load(); - showAllDevicesStats = GlobalReadingStats::hasSyncedStats(); - allDevicesGlobalStats = showAllDevicesStats ? GlobalReadingStats::loadAggregated(globalStats) : globalStats; - bookStatsCached = false; - updateHighlightedBookContext(); - } - requestUpdate(); - }); } } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index cd9665aafbc..12b82008488 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -14,6 +14,7 @@ #include "activities/Activity.h" #include "activities/reader/BookReadingStats.h" #include "activities/reader/GlobalReadingStats.h" +#include "components/CoverGridHomeUi.h" #include "components/OptionPopup.h" #include "util/ButtonNavigator.h" @@ -30,6 +31,8 @@ class HomeActivity final : public Activity { private: ButtonNavigator buttonNavigator; + std::unique_ptr coverGridUi; + bool gridHasContinueReading = false; int selectorIndex = 0; int lastCarouselBookIndex = 0; // remembered position when leaving carousel row int carouselCoverTouchDownIndex = -1; @@ -126,6 +129,9 @@ class HomeActivity final : public Activity { void showNextRecentBookOnHome(); void updateHighlightedBookContext(bool allowChapterTitleRead = true); void loadRecentBooks(int maxBooks); + void fillCoverGridFromLibrary(); + void loadCoverGridThumbnails(); + void activateCoverGridSelection(); void loadAllBookStats(); void loadRecentCovers(int coverHeight); diff --git a/src/activities/library/LibraryActivity.cpp b/src/activities/library/LibraryActivity.cpp index 4167e584685..84e9b4645a2 100644 --- a/src/activities/library/LibraryActivity.cpp +++ b/src/activities/library/LibraryActivity.cpp @@ -1,7 +1,10 @@ #include "LibraryActivity.h" #include +#include #include +#include +#include #include #include #include @@ -20,6 +23,7 @@ #include "activities/home/BookActions.h" #include "activities/home/FileBrowserActionActivity.h" +#include "activities/home/RecentBookProgress.h" #include "activities/library/LibraryPrewarm.h" #include "activities/library/LibrarySettingsActivity.h" #include "activities/reader/BookReadingStats.h" @@ -46,6 +50,30 @@ constexpr int HEADER_CONTROL_GAP = 10; // the row it started on. Matches the SDK's stationary tap slop. constexpr int DRAG_TAP_SLOP_PX = 28; constexpr int FOOTER_HEIGHT = 28; +constexpr int GRID_COLUMNS = 3; +constexpr int GRID_PAGE_SIZE = 9; +constexpr int GRID_GAP = 8; +constexpr int GRID_SELECTION_PADDING = 4; +constexpr int GRID_SELECTION_OUTLINE_GAP = 2; +constexpr int GRID_SELECTION_OUTER_INSET = GRID_SELECTION_PADDING + GRID_SELECTION_OUTLINE_GAP; +constexpr int GRID_COVER_CORNER_RADIUS = 2; + +const RecentBook* recentBookForPath(const std::string& path) { + const auto& books = RECENT_BOOKS.getBooks(); + const auto it = + std::find_if(books.begin(), books.end(), [&path](const RecentBook& book) { return book.path == path; }); + return it == books.end() ? nullptr : &*it; +} + +bool hasValidGridThumb(const std::string& path, const int width, const int height) { + FsFile file; + if (path.empty() || !Storage.exists(path.c_str()) || !Storage.openFileForRead("LIB", path, file)) return false; + Bitmap bitmap(file); + const bool valid = + bitmap.parseHeaders() == BmpReaderError::Ok && bitmap.getWidth() == width && bitmap.getHeight() == height; + file.close(); + return valid; +} int headerControlRightInset() { const auto& metrics = UITheme::getInstance().getMetrics(); @@ -62,6 +90,10 @@ LibraryActivity::LibraryActivity(GfxRenderer& renderer, MappedInputManager& mapp void LibraryActivity::onEnter() { RenderLock lock; Activity::onEnter(); + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + // The activity can be constructed while a landscape reader is still active. + // Refresh FreeInkUI's captured screen size and safe area after rotating. + app.setDevice(uiTarget.deviceContext()); if (RECENT_BOOKS.pruneMissing()) RECENT_BOOKS.saveToFile(); applySharedUiTheme(app, uiTarget); seriesScratch.reserve(128); @@ -218,6 +250,27 @@ int LibraryActivity::rowCount() const { return index.bookCount(); } +bool LibraryActivity::gridEnabled() const { + return sort == Sort::RecentlyRead && SETTINGS.recentBooksView == CrossPointSettings::RECENT_BOOKS_GRID; +} + +void LibraryActivity::loadGridProgress() { + const int row = selection - CONTROL_COUNT; + if (!gridEnabled() || row < 0 || row >= rowCount()) { + gridProgressRow = -1; + gridProgress = -1.0f; + return; + } + if (gridProgressRow == row) return; + gridProgressRow = row; + gridProgress = -1.0f; + RecentBook book; + if (readBook(row, book)) { + gridProgress = FsHelpers::hasEpubExtension(book.path) ? RecentBookProgress::loadCachedEpubPercent(book) + : RecentBookProgress::loadPercent(book); + } +} + uint16_t LibraryActivity::ordinalForRow(const int row) { if (row < 0 || row >= rowCount()) return UINT16_MAX; if (hasActiveFilter()) return filtered ? filtered[row] : UINT16_MAX; @@ -364,8 +417,13 @@ void LibraryActivity::resetViewport() { selection = rowCount() || !mappedInput.hasTouchHardware() ? CONTROL_COUNT : 3; showSelection = !mappedInput.hasTouchHardware(); topIndex = 0; + gridPageStart = 0; + loadedGridPageStart = -1; + nextGridCoverRow = -1; + gridProgressRow = -1; listNav.reset(selection - CONTROL_COUNT); uiReady = false; + loadGridProgress(); } void LibraryActivity::reloadAfterBookAction() { @@ -377,6 +435,11 @@ void LibraryActivity::reloadAfterBookAction() { listNav.top = topIndex; listNav.follow(rowCount()); topIndex = listNav.top; + gridPageStart = ((selection - CONTROL_COUNT) / GRID_PAGE_SIZE) * GRID_PAGE_SIZE; + loadedGridPageStart = -1; + nextGridCoverRow = -1; + gridProgressRow = -1; + loadGridProgress(); requestUpdate(); } @@ -499,6 +562,7 @@ void LibraryActivity::activateControl(const int control) { if (!SETTINGS.saveToFile()) LOG_ERR("LIB", "Cannot save Library sort direction"); applyFilter(); topIndex = 0; + if (gridEnabled()) resetViewport(); requestUpdate(); } } @@ -562,8 +626,14 @@ void LibraryActivity::latchInput() { const auto swipe = mappedInput.wasSwipe(); if (released) touchTracking = false; - if (swipe == MappedInputManager::SwipeDir::Up && pending.scrollPages < INT8_MAX) ++pending.scrollPages; - if (swipe == MappedInputManager::SwipeDir::Down && pending.scrollPages > INT8_MIN) --pending.scrollPages; + // The cover grid also pages with horizontal swipes. + const bool gridSwipe = gridEnabled(); + if ((swipe == MappedInputManager::SwipeDir::Up || (gridSwipe && swipe == MappedInputManager::SwipeDir::Left)) && + pending.scrollPages < INT8_MAX) + ++pending.scrollPages; + if ((swipe == MappedInputManager::SwipeDir::Down || (gridSwipe && swipe == MappedInputManager::SwipeDir::Right)) && + pending.scrollPages > INT8_MIN) + --pending.scrollPages; if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) pending.confirmReleased = true; if (mappedInput.wasReleased(MappedInputManager::Button::Back)) pending.backReleased = true; @@ -661,10 +731,20 @@ void LibraryActivity::loop() { } if (input.scrollPages != 0) { if (mappedInput.hasTouchHardware()) showSelection = false; - listNav.top = topIndex; - const int page = listNav.pageRowsFor(rowCount()); - listNav.scrollBy(input.scrollPages * page, rowCount()); - topIndex = listNav.top; + if (gridEnabled()) { + const int lastPage = rowCount() > 0 ? ((rowCount() - 1) / GRID_PAGE_SIZE) * GRID_PAGE_SIZE : 0; + gridPageStart = std::clamp(gridPageStart + input.scrollPages * GRID_PAGE_SIZE, 0, lastPage); + if (rowCount() > 0) selection = CONTROL_COUNT + gridPageStart; + loadedGridPageStart = -1; + nextGridCoverRow = -1; + gridProgressRow = -1; + loadGridProgress(); + } else { + listNav.top = topIndex; + const int page = listNav.pageRowsFor(rowCount()); + listNav.scrollBy(input.scrollPages * page, rowCount()); + topIndex = listNav.top; + } requestUpdate(); return; } @@ -680,11 +760,22 @@ void LibraryActivity::loop() { const auto move = [this](const int next) { if (!showSelection) { showSelection = true; + loadGridProgress(); requestUpdate(); return; } selection = next; - if (selection >= CONTROL_COUNT) { + if (gridEnabled()) { + if (selection >= CONTROL_COUNT) { + const int nextPage = ((selection - CONTROL_COUNT) / GRID_PAGE_SIZE) * GRID_PAGE_SIZE; + if (nextPage != gridPageStart) { + loadedGridPageStart = -1; + nextGridCoverRow = -1; + } + gridPageStart = nextPage; + } + loadGridProgress(); + } else if (selection >= CONTROL_COUNT) { listNav.selected = selection - CONTROL_COUNT; listNav.top = topIndex; listNav.follow(rowCount()); @@ -698,7 +789,7 @@ void LibraryActivity::loop() { : ButtonNavigator::previousIndex(selection, count)); } for (int i = 0; i < std::abs(input.pageSteps); ++i) { - const int page = listNav.pageRowsFor(rowCount()); + const int page = gridEnabled() ? GRID_PAGE_SIZE : listNav.pageRowsFor(rowCount()); move(input.pageSteps > 0 ? ButtonNavigator::nextPageIndex(selection, count, page) : ButtonNavigator::previousPageIndex(selection, count, page)); } @@ -713,11 +804,14 @@ void LibraryActivity::loop() { } for (int i = 0; i < std::abs(input.pageSteps); ++i) { const int row = selection - CONTROL_COUNT; - const int page = listNav.pageRowsFor(bookCount); + const int page = gridEnabled() ? GRID_PAGE_SIZE : listNav.pageRowsFor(bookCount); move(CONTROL_COUNT + (input.pageSteps > 0 ? ButtonNavigator::nextPageIndex(row, bookCount, page) : ButtonNavigator::previousPageIndex(row, bookCount, page))); } } + // Prepare at most one visible cover per turn, leaving an input check between + // EPUB parses. Redraw as each thumbnail becomes available. + loadGridPageCovers(); } void LibraryActivity::listScreen(UiApp::ScreenType& screen, void* user) { @@ -859,7 +953,7 @@ uint32_t LibraryActivity::groupForRow(const int row) { void LibraryActivity::buildSortHeader(UiApp::ScreenType& screen) { const int16_t bandHeight = std::max(44, screen.theme().rowHeight); const auto band = screen.take(fui::LayoutAnchor::Top, bandHeight); - const int16_t margin = 10; + const int16_t margin = UITheme::getInstance().getMetrics().contentSidePadding; auto labelStyle = screen.theme().bodyText; labelStyle.bold = true; const int16_t labelWidth = std::min( @@ -970,6 +1064,10 @@ void LibraryActivity::buildListScreen(UiApp::ScreenType& screen) { } return; } + if (gridEnabled()) { + buildGrid(screen); + return; + } fui::ListProps props; props.rowProvider = &LibraryActivity::provideRow; props.rowProviderCtx = this; @@ -993,6 +1091,165 @@ void LibraryActivity::buildListScreen(UiApp::ScreenType& screen) { screen.list(props); } +void LibraryActivity::buildGrid(UiApp::ScreenType& screen) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const int count = rowCount(); + const int pageCount = (count + GRID_PAGE_SIZE - 1) / GRID_PAGE_SIZE; + const auto title = screen.take(fui::LayoutAnchor::Top, + static_cast(uiTarget.lineHeight(screen.theme().bodyText.font) + GRID_GAP)); + fui::Rect pageIndicator{}; + if (pageCount > 1) pageIndicator = screen.take(fui::LayoutAnchor::Bottom, 16, GRID_GAP); + const fui::Rect area = screen.body(); + const int16_t cellWidth = std::max(1, (area.width - 2 * GRID_GAP) / GRID_COLUMNS); + const int16_t cellHeight = std::max(1, (area.height - 2 * GRID_GAP) / GRID_COLUMNS); + const int16_t coverHeight = + std::max(1, std::min(cellHeight - 2 * GRID_SELECTION_OUTER_INSET, + (cellWidth - 2 * GRID_SELECTION_OUTER_INSET) * 3 / 2)); + const int16_t coverWidth = + std::max(1, std::min(cellWidth - 2 * GRID_SELECTION_OUTER_INSET, coverHeight * 2 / 3)); + if (coverWidth != gridCoverWidth || coverHeight != gridCoverHeight) { + gridCoverWidth = coverWidth; + gridCoverHeight = coverHeight; + loadedGridPageStart = -1; + nextGridCoverRow = -1; + } + + const int selectedRow = selection - CONTROL_COUNT; + if (selectedRow >= gridPageStart && selectedRow < gridPageStart + GRID_PAGE_SIZE && selectedRow < count && + readBook(selectedRow, rowScratch)) { + const bool hasProgress = gridProgressRow == selectedRow && RecentBookProgress::hasPercent(gridProgress); + subtitleScratch.clear(); + if (hasProgress) subtitleScratch.assign(" · ").append(RecentBookProgress::formatPercent(gridProgress)); + auto style = screen.theme().bodyText; + style.maxLines = 1; + // Center the whole title row between the sort divider and the first cover. + const int16_t coverTop = static_cast(area.y + (cellHeight - coverHeight) / 2); + const int16_t bandTop = static_cast(title.y - metrics.verticalSpacing); + const fui::Rect titleBand{title.x, bandTop, title.width, static_cast(coverTop - bandTop)}; + const fui::Rect textBand = titleBand.inset(fui::Insets{0, static_cast(metrics.contentSidePadding), 0, + static_cast(metrics.contentSidePadding)}); + const int16_t suffixWidth = + hasProgress ? uiTarget.measureText(style.font, subtitleScratch.c_str(), style).width : 0; + const int16_t titleWidth = std::max(0, textBand.width - suffixWidth); + const std::string visibleTitle = + renderer.truncatedText(uiScaleSpec().bodyFontId, rowScratch.title.c_str(), titleWidth, + style.bold ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); + const int16_t drawnTitleWidth = uiTarget.measureText(style.font, visibleTitle.c_str(), style).width; + uiTarget.text(fui::Rect{textBand.x, textBand.y, titleWidth, textBand.height}, visibleTitle.c_str(), style); + if (hasProgress) + uiTarget.text( + fui::Rect{static_cast(textBand.x + drawnTitleWidth), textBand.y, suffixWidth, textBand.height}, + subtitleScratch.c_str(), style); + } + + const int visible = std::min(GRID_PAGE_SIZE, count - gridPageStart); + for (int slot = 0; slot < visible; ++slot) { + const int row = gridPageStart + slot; + const int col = slot % GRID_COLUMNS; + const int line = slot / GRID_COLUMNS; + const fui::Rect cell{static_cast(area.x + col * (cellWidth + GRID_GAP)), + static_cast(area.y + line * (cellHeight + GRID_GAP)), cellWidth, cellHeight}; + const int16_t x = static_cast(cell.x + (cell.width - coverWidth) / 2); + const int16_t y = static_cast(cell.y + (cell.height - coverHeight) / 2); + bool drawn = false; + const bool available = readBook(row, rowScratch); + if (available) { + const RecentBook* recent = recentBookForPath(rowScratch.path); + if (recent && !recent->coverBmpPath.empty()) { + const std::string path = UITheme::getCoverThumbPath(recent->coverBmpPath, coverWidth, coverHeight); + if (!path.empty() && Storage.exists(path.c_str())) { + FsFile file; + if (Storage.openFileForRead("LIB", path, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok && bitmap.getWidth() > 0 && bitmap.getHeight() > 0) + drawn = renderer.drawBitmap(bitmap, x, y, coverWidth, coverHeight); + file.close(); + } + } + } + } + if (!drawn) { + renderer.fillRoundedRect(x, y, coverWidth, coverHeight, GRID_COVER_CORNER_RADIUS, Color::White); + if (coverWidth >= 36 && coverHeight >= 36) + drawLucideIcon(renderer, icon_book_marked_32, x + (coverWidth - 32) / 2, y + (coverHeight - 32) / 2); + } + renderer.maskRoundedRectOutsideCorners(x, y, coverWidth, coverHeight, GRID_COVER_CORNER_RADIUS, Color::White); + renderer.drawRoundedRect(x, y, coverWidth, coverHeight, 2, GRID_COVER_CORNER_RADIUS, true); + if (row == selectedRow) { + renderer.drawRoundedRect(x - GRID_SELECTION_PADDING, y - GRID_SELECTION_PADDING, + coverWidth + 2 * GRID_SELECTION_PADDING, coverHeight + 2 * GRID_SELECTION_PADDING, 3, + GRID_COVER_CORNER_RADIUS + GRID_SELECTION_PADDING, true); + renderer.drawRoundedRect( + x - GRID_SELECTION_OUTER_INSET, y - GRID_SELECTION_OUTER_INSET, coverWidth + 2 * GRID_SELECTION_OUTER_INSET, + coverHeight + 2 * GRID_SELECTION_OUTER_INSET, 1, GRID_COVER_CORNER_RADIUS + GRID_SELECTION_OUTER_INSET, true); + } + if (available) + screen.frame().hit(cell, ACTION_ROW, static_cast(row), fui::InputTouch | fui::InputLongPress); + } + + if (pageCount > 1) { + constexpr int dotSize = 6; + constexpr int dotGap = 8; + const int dotsWidth = pageCount * dotSize + (pageCount - 1) * dotGap; + const int startX = pageIndicator.x + (pageIndicator.width - dotsWidth) / 2; + for (int page = 0; page < pageCount; ++page) { + const int x = startX + page * (dotSize + dotGap); + if (page == gridPageStart / GRID_PAGE_SIZE) + renderer.fillRect(x, pageIndicator.y + 4, dotSize, dotSize, true); + else + renderer.drawRect(x, pageIndicator.y + 4, dotSize, dotSize, true); + } + } +} + +void LibraryActivity::loadGridPageCovers() { + if (!gridEnabled() || gridCoverWidth <= 0 || gridCoverHeight <= 0 || gridPageStart == loadedGridPageStart) return; + const int pageEnd = std::min(gridPageStart + GRID_PAGE_SIZE, rowCount()); + if (nextGridCoverRow < 0) nextGridCoverRow = gridPageStart; + if (nextGridCoverRow < pageEnd && loadGridCover(nextGridCoverRow++)) requestUpdate(); + if (nextGridCoverRow >= pageEnd) { + loadedGridPageStart = gridPageStart; + nextGridCoverRow = -1; + } +} + +bool LibraryActivity::loadGridCover(const int row) { + RecentBook book; + if (!readBook(row, book)) return false; + const RecentBook* recent = recentBookForPath(book.path); + if (!recent || recent->coverState == RecentBook::CoverState::Missing) return false; + if (!FsHelpers::hasEpubExtension(book.path) && !FsHelpers::hasXtcExtension(book.path)) return false; + const std::string thumbPath = UITheme::getCoverThumbPath(recent->coverBmpPath, gridCoverWidth, gridCoverHeight); + if (hasValidGridThumb(thumbPath, gridCoverWidth, gridCoverHeight)) return false; + if (FsHelpers::hasEpubExtension(book.path)) { + Epub epub(book.path, "/.crosspoint"); + if (!epub.load(true, true, Epub::XLocationLoadMode::Skip)) { + LOG_ERR("LIB", "Cannot load EPUB cover for %s", book.path.c_str()); + return false; + } + if (epub.generateThumbBmp(gridCoverWidth, gridCoverHeight, &renderer, SETTINGS.getReaderFontId())) { + if (!RECENT_BOOKS.updateBook(book.path, recent->title, recent->author, epub.getThumbBmpPath(), + recent->coverState)) + LOG_ERR("LIB", "Cannot update EPUB cover path for %s", book.path.c_str()); + return true; + } + if (!epub.hasCoverImage()) { + if (!RECENT_BOOKS.updateBook(book.path, recent->title, recent->author, "", RecentBook::CoverState::Missing)) + LOG_ERR("LIB", "Cannot mark missing EPUB cover for %s", book.path.c_str()); + } + return false; + } + Xtc xtc(book.path, "/.crosspoint"); + if (!xtc.load()) { + LOG_ERR("LIB", "Cannot load XTC cover for %s", book.path.c_str()); + return false; + } + if (!xtc.generateThumbBmp(gridCoverWidth, gridCoverHeight)) return false; + if (!RECENT_BOOKS.updateBook(book.path, recent->title, recent->author, xtc.getThumbBmpPath(), recent->coverState)) + LOG_ERR("LIB", "Cannot update XTC cover path for %s", book.path.c_str()); + return true; +} + void LibraryActivity::render(RenderLock&&) { uiReady = false; for (int pass = 0; pass < 8; ++pass) { @@ -1095,6 +1352,15 @@ void LibraryActivity::showBookActionMenu(const size_t bookIndex, const bool igno } switch (static_cast(actionResult->action)) { + case FileBrowserAction::ToggleBookStatsTracking: { + bool enabled = false; + if (!BookActions::toggleBookStatsTracking(book.path, enabled)) { + const std::string error = std::string(tr(STR_TRACK_READING_STATS)) + " " + tr(STR_FAILED_LOWER); + BookActions::drawToast(renderer, error.c_str()); + } + reloadAfterBookAction(); + return; + } case FileBrowserAction::ReadingStats: openDialog(BookActions::createReadingStatsActivity(renderer, mappedInput, book.path, book.title), [this](const ActivityResult&) { requestUpdate(); }); diff --git a/src/activities/library/LibraryActivity.h b/src/activities/library/LibraryActivity.h index 320cd6caebc..9720e4ce998 100644 --- a/src/activities/library/LibraryActivity.h +++ b/src/activities/library/LibraryActivity.h @@ -37,6 +37,13 @@ class LibraryActivity final : public Activity { int selection = CONTROL_COUNT; bool showSelection = true; int topIndex = 0; + int gridPageStart = 0; + int loadedGridPageStart = -1; + int nextGridCoverRow = -1; + int16_t gridCoverWidth = 0; + int16_t gridCoverHeight = 0; + int gridProgressRow = -1; + float gridProgress = -1.0f; bool uiReady = false; bool longPressFired = false; bool ignoreConfirmRelease = false; @@ -112,6 +119,11 @@ class LibraryActivity final : public Activity { static void provideRow(void* user, uint16_t row, freeink::ui::ListItem& item); void latchInput(); void buildListScreen(UiApp::ScreenType& screen); + void buildGrid(UiApp::ScreenType& screen); + void loadGridPageCovers(); + bool loadGridCover(int row); + void loadGridProgress(); + bool gridEnabled() const; void buildSortHeader(UiApp::ScreenType& screen); const char* sortLabel() const; library::SortOrder indexOrder() const; diff --git a/src/activities/library/LibrarySettingsActivity.cpp b/src/activities/library/LibrarySettingsActivity.cpp index 21e6da70e0e..7ad9f0118e5 100644 --- a/src/activities/library/LibrarySettingsActivity.cpp +++ b/src/activities/library/LibrarySettingsActivity.cpp @@ -10,7 +10,7 @@ namespace fui = freeink::ui; namespace { constexpr fui::ActionId ACTION_ROW = 1; -constexpr int ROW_COUNT = 9; +constexpr int ROW_COUNT = 10; } // namespace LibrarySettingsActivity::LibrarySettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) @@ -37,24 +37,29 @@ void LibrarySettingsActivity::toggle(const int row) { SETTINGS.libraryListExpanded = !SETTINGS.libraryListExpanded; break; case 2: - SETTINGS.libraryHideFinishedBooks = !SETTINGS.libraryHideFinishedBooks; + SETTINGS.recentBooksView = SETTINGS.recentBooksView == CrossPointSettings::RECENT_BOOKS_GRID + ? CrossPointSettings::RECENT_BOOKS_LIST + : CrossPointSettings::RECENT_BOOKS_GRID; break; case 3: - SETTINGS.libraryShowSeries = !SETTINGS.libraryShowSeries; + SETTINGS.libraryHideFinishedBooks = !SETTINGS.libraryHideFinishedBooks; break; case 4: - SETTINGS.libraryShowGenre = !SETTINGS.libraryShowGenre; + SETTINGS.libraryShowSeries = !SETTINGS.libraryShowSeries; break; case 5: - SETTINGS.libraryShowEpub = !SETTINGS.libraryShowEpub; + SETTINGS.libraryShowGenre = !SETTINGS.libraryShowGenre; break; case 6: - SETTINGS.libraryShowXtc = !SETTINGS.libraryShowXtc; + SETTINGS.libraryShowEpub = !SETTINGS.libraryShowEpub; break; case 7: - SETTINGS.libraryShowTxt = !SETTINGS.libraryShowTxt; + SETTINGS.libraryShowXtc = !SETTINGS.libraryShowXtc; break; case 8: + SETTINGS.libraryShowTxt = !SETTINGS.libraryShowTxt; + break; + case 9: SETTINGS.libraryShowMarkdown = !SETTINGS.libraryShowMarkdown; break; default: @@ -135,41 +140,47 @@ void LibrarySettingsActivity::screen(UiApp::ScreenType& screen, void* user) { void LibrarySettingsActivity::provideRow(void*, const uint16_t row, fui::ListItem& item) { static constexpr StrId labels[] = { - StrId::STR_LIBRARY_USE_METADATA, StrId::STR_LIBRARY_LIST_VIEW, StrId::STR_LIBRARY_FINISHED_BOOKS, - StrId::STR_LIBRARY_SERIES, StrId::STR_LIBRARY_GENRE, StrId::STR_LIBRARY_EPUBS, - StrId::STR_LIBRARY_XTC_XTCH, StrId::STR_LIBRARY_TXT, StrId::STR_LIBRARY_MARKDOWN}; + StrId::STR_LIBRARY_USE_METADATA, StrId::STR_LIBRARY_LIST_VIEW, StrId::STR_LIBRARY_RECENTLY_OPENED_VIEW, + StrId::STR_LIBRARY_FINISHED_BOOKS, StrId::STR_LIBRARY_SERIES, StrId::STR_LIBRARY_GENRE, + StrId::STR_LIBRARY_EPUBS, StrId::STR_LIBRARY_XTC_XTCH, StrId::STR_LIBRARY_TXT, + StrId::STR_LIBRARY_MARKDOWN}; item.label = I18N.get(labels[row]); item.actionValue = static_cast(row); if (row == 1) { item.value = SETTINGS.libraryListExpanded ? tr(STR_LIBRARY_EXPANDED) : tr(STR_COMPACT); return; } + if (row == 2) { + item.value = + SETTINGS.recentBooksView == CrossPointSettings::RECENT_BOOKS_GRID ? tr(STR_GRID_VIEW) : tr(STR_LIST_VIEW); + return; + } item.toggle = true; switch (row) { case 0: item.toggleChecked = SETTINGS.libraryUseMetadata; break; - case 2: + case 3: item.sectionHeading = tr(STR_CAT_DISPLAY); item.toggleChecked = !SETTINGS.libraryHideFinishedBooks; break; - case 3: + case 4: item.toggleChecked = SETTINGS.libraryShowSeries; break; - case 4: + case 5: item.toggleChecked = SETTINGS.libraryShowGenre; break; - case 5: + case 6: item.sectionHeading = tr(STR_LIBRARY_SHOW_FILES); item.toggleChecked = SETTINGS.libraryShowEpub; break; - case 6: + case 7: item.toggleChecked = SETTINGS.libraryShowXtc; break; - case 7: + case 8: item.toggleChecked = SETTINGS.libraryShowTxt; break; - case 8: + case 9: item.toggleChecked = SETTINGS.libraryShowMarkdown; break; } diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index 4d15e6b1b4b..a9b08a75dd2 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -3,6 +3,7 @@ #include #include +#include "CrossPointSettings.h" #include "MappedInputManager.h" #include "components/TouchHeaderBackButton.h" #include "components/UITheme.h" @@ -41,8 +42,9 @@ constexpr StrId menuDescs[] = {StrId::STR_JOIN_DESC, StrId::STR_CALIBRE_DESC, St constexpr UIIcon menuIcons[] = {UIIcon::Wifi, UIIcon::Library, UIIcon::Hotspot, UIIcon::Transfer, UIIcon::Transfer}; #endif -constexpr int MENU_ITEM_COUNT = sizeof(menuModes) / sizeof(menuModes[0]); -constexpr int LIST_ITEM_COUNT = MENU_ITEM_COUNT + 1; +constexpr int ALL_MENU_ITEM_COUNT = sizeof(menuModes) / sizeof(menuModes[0]); +int menuItemCount() { return ALL_MENU_ITEM_COUNT - (SETTINGS.shouldTrackReadingStats() ? 0 : 1); } +int listItemCount() { return menuItemCount() + 1; } constexpr int NEARBY_SECTION_INDEX = CROSSINK_APP_CAP_USB_DRIVE ? 4 : 3; int listIndexForMenuIndex(const int menuIndex) { return menuIndex < NEARBY_SECTION_INDEX ? menuIndex : menuIndex + 1; } @@ -52,12 +54,12 @@ NetworkModeSelectionActivity::NetworkModeSelectionActivity(GfxRenderer& renderer : Activity("NetworkModeSelection", renderer, mappedInput), ui(renderer) {} void NetworkModeSelectionActivity::selectCurrent() { - if (selectedIndex >= 0 && selectedIndex < MENU_ITEM_COUNT) onModeSelected(menuModes[selectedIndex]); + if (selectedIndex >= 0 && selectedIndex < menuItemCount()) onModeSelected(menuModes[selectedIndex]); } void NetworkModeSelectionActivity::onRowEvent(const fui::ActionEvent& event, void* user) { auto* self = static_cast(user); - if (event.value < 0 || event.value >= MENU_ITEM_COUNT) return; + if (event.value < 0 || event.value >= menuItemCount()) return; self->selectedIndex = event.value; // Selection leaves this screen; a lingering flash would gray an unrelated // element on the next render. @@ -123,8 +125,8 @@ void NetworkModeSelectionActivity::loop() { // Page by the rows the last layout measured, not the fixed-height // estimate: wrapped subtitles fit fewer rows, and paging by the larger // estimate would skip the ones in between. - const int page = listNav.pageRowsFor(LIST_ITEM_COUNT); - moved = listNav.scrollBy(swipe == MappedInputManager::SwipeDir::Up ? page : -page, LIST_ITEM_COUNT); + const int page = listNav.pageRowsFor(listItemCount()); + moved = listNav.scrollBy(swipe == MappedInputManager::SwipeDir::Up ? page : -page, listItemCount()); topIndex = listNav.top; } if (moved) { @@ -137,12 +139,12 @@ void NetworkModeSelectionActivity::loop() { buttonNavigator.onNext([this] { { RenderLock lock(*this); - selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT); + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, menuItemCount()); buttonSelectionActive = true; listNav.selected = listIndexForMenuIndex(selectedIndex); listNav.top = topIndex; listNav.visibleRows = visibleRows; - listNav.follow(LIST_ITEM_COUNT); + listNav.follow(listItemCount()); topIndex = listNav.top; } requestUpdate(); @@ -151,12 +153,12 @@ void NetworkModeSelectionActivity::loop() { buttonNavigator.onPrevious([this] { { RenderLock lock(*this); - selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEM_COUNT); + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, menuItemCount()); buttonSelectionActive = true; listNav.selected = listIndexForMenuIndex(selectedIndex); listNav.top = topIndex; listNav.visibleRows = visibleRows; - listNav.follow(LIST_ITEM_COUNT); + listNav.follow(listItemCount()); topIndex = listNav.top; } requestUpdate(); @@ -176,8 +178,8 @@ void NetworkModeSelectionActivity::buildListScreen(UiApp::ScreenType& screen) { screen.spacer(static_cast(metrics.verticalSpacing)); std::vector items; - items.reserve(LIST_ITEM_COUNT); - for (int i = 0; i < MENU_ITEM_COUNT; i++) { + items.reserve(listItemCount()); + for (int i = 0; i < menuItemCount(); i++) { if (i == NEARBY_SECTION_INDEX) { fui::ListItem header; header.label = I18N.get(StrId::STR_NEARBY_DEVICE); @@ -222,7 +224,7 @@ void NetworkModeSelectionActivity::buildListScreen(UiApp::ScreenType& screen) { listNav.selected = buttonSelectionActive ? listIndexForMenuIndex(selectedIndex) : -1; listNav.top = topIndex; listNav.visibleRows = visibleRows; - listNav.syncToProps(screen.body(), props.rowHeight, props.rowGap, LIST_ITEM_COUNT, props); + listNav.syncToProps(screen.body(), props.rowHeight, props.rowGap, listItemCount(), props); topIndex = listNav.top; screen.list(props); topIndex = listNav.top; diff --git a/src/activities/reader/BookStatsTracking.cpp b/src/activities/reader/BookStatsTracking.cpp new file mode 100644 index 00000000000..e42104afac2 --- /dev/null +++ b/src/activities/reader/BookStatsTracking.cpp @@ -0,0 +1,54 @@ +#include "BookStatsTracking.h" + +#include +#include + +#include "CrossPointSettings.h" + +namespace { +constexpr char MARKER_NAME[] = "/reading_stats_off"; + +std::string markerPath(const std::string& cachePath) { return cachePath + MARKER_NAME; } +} // namespace + +namespace BookStatsTracking { +bool isBookEnabled(const std::string& cachePath) { + return cachePath.empty() || !Storage.exists(markerPath(cachePath).c_str()); +} + +bool isEnabled(const std::string& cachePath) { return SETTINGS.shouldTrackReadingStats() && isBookEnabled(cachePath); } + +bool setBookEnabled(const std::string& cachePath, const bool enabled) { + if (cachePath.empty()) { + LOG_ERR("BSTAT", "Missing cache path for book tracking setting"); + return false; + } + const std::string path = markerPath(cachePath); + if (enabled) { + if (!Storage.exists(path.c_str())) return true; + if (Storage.remove(path.c_str())) return true; + LOG_ERR("BSTAT", "Could not enable book stats: %s", path.c_str()); + return false; + } + if (Storage.exists(path.c_str())) return true; + if (!Storage.exists(cachePath.c_str()) && !Storage.mkdir(cachePath.c_str())) { + LOG_ERR("BSTAT", "Could not create book cache: %s", cachePath.c_str()); + return false; + } + const std::string tempPath = path + ".tmp"; + FsFile file; + if (!Storage.openFileForWrite("BSTAT", tempPath, file)) { + LOG_ERR("BSTAT", "Could not disable book stats: %s", path.c_str()); + return false; + } + const uint8_t marker = 1; + const bool written = file.write(&marker, sizeof(marker)) == sizeof(marker) && file.sync(); + const bool closed = file.close(); + if (!written || !closed || !Storage.rename(tempPath.c_str(), path.c_str())) { + Storage.remove(tempPath.c_str()); + LOG_ERR("BSTAT", "Could not finish book tracking setting: %s", path.c_str()); + return false; + } + return true; +} +} // namespace BookStatsTracking diff --git a/src/activities/reader/BookStatsTracking.h b/src/activities/reader/BookStatsTracking.h new file mode 100644 index 00000000000..170a4049791 --- /dev/null +++ b/src/activities/reader/BookStatsTracking.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +// A book is enabled by default. The marker is separate from stats_v5.bin so +// clearing statistics or EPUB reader settings does not reset the user's choice. +namespace BookStatsTracking { +bool isBookEnabled(const std::string& cachePath); +bool isEnabled(const std::string& cachePath); +bool setBookEnabled(const std::string& cachePath, bool enabled); +} // namespace BookStatsTracking diff --git a/src/activities/reader/ControlsOptionsActivity.cpp b/src/activities/reader/ControlsOptionsActivity.cpp index ab49309cf18..4e8e0da6827 100644 --- a/src/activities/reader/ControlsOptionsActivity.cpp +++ b/src/activities/reader/ControlsOptionsActivity.cpp @@ -205,7 +205,7 @@ void ControlsOptionsActivity::openEnumOptionPicker(const SettingInfo& setting) { std::vector options; options.reserve(optionCount); for (uint8_t i = 0; i < optionCount; i++) { - options.push_back(settingEnumOptionLabel(setting, i)); + options.push_back(sideButtonOptionLabel(setting, i)); } uint8_t currentIndex = 0; @@ -391,6 +391,8 @@ void ControlsOptionsActivity::buildOptionsScreen(UiApp::ScreenType& screen) { } const auto& currentSettingsList = *currentSettings; + const std::string leftUpLabel = sideButtonGroupLabel(true); + const std::string rightDownLabel = sideButtonGroupLabel(false); std::vector values(currentSettingsList.size()); std::vector items; items.reserve(currentSettingsList.size()); @@ -402,7 +404,7 @@ void ControlsOptionsActivity::buildOptionsScreen(UiApp::ScreenType& screen) { values[i] = SETTINGS.*(setting.valuePtr) ? tr(STR_STATE_ON) : tr(STR_STATE_OFF); } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { const uint8_t displayValue = enumDisplayIndexForRawValue(setting, SETTINGS.*(setting.valuePtr)); - values[i] = settingEnumOptionLabel(setting, displayValue < settingEnumOptionCount(setting) ? displayValue : 0); + values[i] = sideButtonOptionLabel(setting, displayValue < settingEnumOptionCount(setting) ? displayValue : 0); } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { values[i] = std::to_string(SETTINGS.*(setting.valuePtr)); } @@ -410,7 +412,11 @@ void ControlsOptionsActivity::buildOptionsScreen(UiApp::ScreenType& screen) { const bool isSectionHeader = setting.type == SettingType::SECTION_HEADER; fui::ListItem item; const fui::BitmapRef directionIcon = swipeActionIcon(setting.nameId); - item.label = isSectionHeader ? uiListSectionHeaderLabel(values[i], I18N.get(setting.nameId)) + const char* sectionLabel = + activeSubmenu == SettingAction::ControlsSideButtons + ? (setting.nameId == StrId::STR_DIR_LEFT ? leftUpLabel.c_str() : rightDownLabel.c_str()) + : I18N.get(setting.nameId); + item.label = isSectionHeader ? uiListSectionHeaderLabel(values[i], sectionLabel) : (directionIcon ? "" : I18N.get(setting.nameId)); item.icon = directionIcon; if (!isSectionHeader && !values[i].empty()) item.value = values[i].c_str(); @@ -433,6 +439,10 @@ void ControlsOptionsActivity::buildOptionsScreen(UiApp::ScreenType& screen) { props.valueInset = 8; props.labelText = screen.theme().bodyText; props.labelText.maxLines = 2; + if (activeSubmenu == SettingAction::ControlsSideButtons) { + props.headerText = screen.theme().smallText; + props.headerText.bold = true; + } configureUiListSectionHeaders(props, screen.theme()); const auto rows = configureUiList(props, screen.theme(), screen.body()); visibleRows = rows > 0 ? rows : 1; diff --git a/src/activities/reader/EndOfBookOptions.cpp b/src/activities/reader/EndOfBookOptions.cpp index 4382eab6679..6e14f66eda0 100644 --- a/src/activities/reader/EndOfBookOptions.cpp +++ b/src/activities/reader/EndOfBookOptions.cpp @@ -116,16 +116,12 @@ EndOfBookOptions::MenuKey EndOfBookOptions::readMenuKey(const MappedInputManager return MenuKey::Back; } - // Selection movement follows reader page-turn buttons: side buttons honor the - // configured side-button layout, while front Left/Right move on release. - const bool sideUsePress = SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_OFF; - const auto sideTriggered = [&](const MappedInputManager::Button button) { - return sideUsePress ? input.wasPressed(button) : input.wasReleased(button); - }; - if (sideTriggered(MappedInputManager::Button::PageBack) || input.wasReleased(MappedInputManager::Button::Left)) { + // Side-button navigation follows each button's configured short action. + if (input.wasReleased(MappedInputManager::Button::PageBack) || input.wasReleased(MappedInputManager::Button::Left)) { return MenuKey::Previous; } - if (sideTriggered(MappedInputManager::Button::PageForward) || input.wasReleased(MappedInputManager::Button::Right)) { + if (input.wasReleased(MappedInputManager::Button::PageForward) || + input.wasReleased(MappedInputManager::Button::Right)) { return MenuKey::Next; } return MenuKey::None; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 1fa5c2d0de9..174cbc18bfa 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -29,6 +29,7 @@ #include "../settings/DictionarySelectActivity.h" #include "../settings/KOReaderSettingsActivity.h" #include "BookStatsActivity.h" +#include "BookStatsTracking.h" #include "ClipSelectionActivity.h" #include "ClippingStore.h" #include "CrossPointSettings.h" @@ -39,6 +40,7 @@ #include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderClippingListActivity.h" #include "EpubReaderDrawerActivity.h" +#include "EpubReaderFootnoteSelectActivity.h" #include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" #include "EpubReaderUtils.h" @@ -1227,6 +1229,8 @@ constexpr std::array READER_SETTING_FIELD constexpr uint32_t SD_FONT_FAMILY_OVERRIDE = 1U << READER_SETTING_FIELDS.size(); constexpr uint32_t ALL_READER_SETTING_OVERRIDES = (SD_FONT_FAMILY_OVERRIDE << 1) - 1; constexpr uint32_t READER_FONT_OVERRIDES = (1U << 0) | (1U << 1) | SD_FONT_FAMILY_OVERRIDE; +// Anti-aliasing changes the page drawing, but not its saved line/page layout. +constexpr uint32_t READER_LAYOUT_SETTING_OVERRIDES = ALL_READER_SETTING_OVERRIDES & ~(1U << 11); constexpr uint32_t SAFE_MODE_SETTING_OVERRIDES = (1U << 9) | (1U << 15) | (1U << 16); uint32_t changedReaderSettingsMask(const ReaderSettingsSnapshot& current, const ReaderSettingsSnapshot& global) { @@ -1559,6 +1563,23 @@ void EpubReaderActivity::pauseReadingPaceTimer(const char* reason) { paceSampleWarmupPending = true; } +void EpubReaderActivity::syncStatsTrackingState() { + const bool enabled = SETTINGS.shouldTrackReadingStats() && bookStatsEnabled; + if (enabled == statsTrackingActive) return; + if (statsTrackingActive && !enabled && epub) { + pendingStatsCommit = true; + if (stats.save(epub->getCachePath())) { + globalStats.save(); + pendingStatsCommit = false; + } + } + statsTrackingActive = enabled; + sessionReadingSeconds = 0; + hasSessionStartLocalDateTime = getCurrentLocalReadingStatsDateTime(sessionStartLocalDateTime); + pageShownAtMs = section && !activeFootnotePreview ? millis() : 0UL; + armReadingPaceWarmup("stats_toggle"); +} + void EpubReaderActivity::resumeReadingPaceTimer(const char*) { if (activeFootnotePreview) { pageShownAtMs = 0UL; @@ -1626,7 +1647,7 @@ void EpubReaderActivity::armReadingPaceWarmup(const char*) { paceSampleWarmupPen bool EpubReaderActivity::forwardPageReadElapsed(uint32_t& seconds, const char*) const { seconds = 0; - if (activeFootnotePreview || !SETTINGS.shouldTrackReadingStats() || pageShownAtMs == 0UL) { + if (activeFootnotePreview || pageShownAtMs == 0UL) { return false; } @@ -1641,7 +1662,7 @@ bool EpubReaderActivity::forwardPageReadElapsed(uint32_t& seconds, const char*) bool EpubReaderActivity::currentPageReadingSecondsForStats(uint32_t& seconds, const char* source) const { seconds = 0; - if (activeFootnotePreview || !SETTINGS.shouldTrackReadingStats() || pageShownAtMs == 0UL) { + if (activeFootnotePreview || !statsTrackingActive || pageShownAtMs == 0UL) { return false; } @@ -1693,6 +1714,7 @@ void EpubReaderActivity::recordForwardPagePaceSample(uint32_t seconds, const cha } stats.recordForwardPageRead(seconds); + paceDirty = true; recoverStoredPaceFromSession("pace_sample"); } @@ -1820,8 +1842,7 @@ bool EpubReaderActivity::estimateProgressTimeLeftSeconds(uint32_t& seconds) cons const float progressPercent = epub->calculateSizeProgress(currentSpineIndex, chapterProgress) * 100.0f; uint32_t currentPageSeconds = 0; uint32_t sessionSeconds = sessionReadingSeconds; - if (SETTINGS.shouldTrackReadingStats() && - currentPageReadingSecondsForStats(currentPageSeconds, "time_left_preview")) { + if (statsTrackingActive && currentPageReadingSecondsForStats(currentPageSeconds, "time_left_preview")) { sessionSeconds = sessionSeconds > UINT32_MAX - currentPageSeconds ? UINT32_MAX : sessionSeconds + currentPageSeconds; } @@ -1862,7 +1883,7 @@ bool EpubReaderActivity::estimateTimeLeftSeconds(const bool bookEstimate, uint32 uint32_t progressEstimateSeconds = 0; bool hasProgressEstimate = false; - if (bookEstimate && hasPace) { + if (bookEstimate && hasPace && statsTrackingActive) { hasProgressEstimate = estimateProgressTimeLeftSeconds(progressEstimateSeconds); } if (!hasPaceEstimate && !hasProgressEstimate) { @@ -2235,7 +2256,9 @@ bool EpubReaderActivity::beginGlobalSettingsEdit() { } void EpubReaderActivity::endGlobalSettingsEdit() { - if (bookReaderSettingsSuspendedForGlobalEdit) { + uint32_t effectiveChanges = 0; + const bool restoreBookFont = bookReaderSettingsSuspendedForGlobalEdit; + if (restoreBookFont) { // 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 @@ -2259,14 +2282,21 @@ void EpubReaderActivity::endGlobalSettingsEdit() { SETTINGS.epubRenderMode = normalizeRenderModeRaw(suspendedBookReaderSettings.epubRenderMode); } captureReaderSettings(initialBookReaderSettings.readerSettings); + effectiveChanges = changedReaderSettingsMask(initialBookReaderSettings.readerSettings, suspendedBookReaderSettings); bookReaderSettingsSuspendedForGlobalEdit = false; } - // 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); + + RenderLock lock(*this); + // The global font picker may have unloaded this book's SD font even when + // its override kept the effective font setting unchanged. + if (restoreBookFont) ensureReaderSdFontLoaded(renderer); + ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); + const ReaderViewportLayout layout = computeReaderViewportLayout( + renderer, automaticPageTurnActive, activeFootnotePreview || !pendingFootnotePreviewAnchor.empty()); + // Reflow only when this book's effective layout changed. Its per-book fields + // stay protected, while inherited global fields and status-bar space update. + if (section && ((effectiveChanges & READER_LAYOUT_SETTING_OVERRIDES) || layout.viewportWidth != buildViewportWidth || + layout.viewportHeight != buildViewportHeight)) { prepareCurrentSectionForRelayout(); section.reset(); } @@ -2425,6 +2455,10 @@ void EpubReaderActivity::onEnter() { // Load reading stats and record session start time. // Session count and reading time are committed on exit once thresholds are met. stats = BookReadingStats::load(epub->getCachePath()); + bookStatsEnabled = BookStatsTracking::isBookEnabled(epub->getCachePath()); + statsTrackingActive = SETTINGS.shouldTrackReadingStats() && bookStatsEnabled; + paceDirty = false; + pendingStatsCommit = false; #if defined(ENABLE_SERIAL_LOG) && LOG_LEVEL >= 2 const uint32_t cumulativeAvgSeconds = stats.totalPagesTurned > 0 ? stats.totalReadingSeconds / stats.totalPagesTurned : 0; @@ -2469,9 +2503,11 @@ void EpubReaderActivity::onExit() { releaseGrayscaleStripScratch(true); ImageBlock::releaseSessionPixelCache(); - // SD-font caches live in the renderer singleton, so leaving them resident after - // the reader exits can fragment the contiguous heap needed for Home cover images. - releaseReaderSdFontCachesForLowMemory(renderer, "ERS", "reader exit"); + // SD-font caches live in the renderer singleton and can fragment the Home heap. + // Release every rebuildable cache, including fonts used before the current one. + if (auto* fontCache = renderer.getFontCacheManager()) { + fontCache->releaseSdFontCaches(); + } MemoryBudget::logEpubHeapPools("reader exit after caches"); Activity::onExit(); @@ -2492,7 +2528,8 @@ void EpubReaderActivity::onExit() { APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); - if (SETTINGS.shouldTrackReadingStats()) { + syncStatsTrackingState(); + if (statsTrackingActive) { recordCurrentPageReadingTime("reader_exit"); // Commit session stats based on active reading time. Page intervals longer @@ -2515,12 +2552,14 @@ void EpubReaderActivity::onExit() { stats.startDate = sessionStartLocalDateTime.date; } } - if (epub) { - recoverStoredPaceFromSession("reader_exit"); - refreshCachedTimeLeftEstimate(); - stats.save(epub->getCachePath()); + } + if (epub) { + recoverStoredPaceFromSession("reader_exit"); + const uint32_t previousEstimate = stats.estimatedTimeLeftSeconds; + refreshCachedTimeLeftEstimate(); + if (statsTrackingActive || paceDirty || pendingStatsCommit || stats.estimatedTimeLeftSeconds != previousEstimate) { + if (stats.save(epub->getCachePath()) && (statsTrackingActive || pendingStatsCommit)) globalStats.save(); } - globalStats.save(); } // Leaving mid-footnote loses the in-RAM return stack on deep sleep; persist the @@ -2625,8 +2664,9 @@ void EpubReaderActivity::openReaderMenu() { 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, + SETTINGS.shouldTrackReadingStats(), bookStatsEnabled, stableCurrentPage, stablePageCount, + getAutoPageTurnIntervalSeconds(), automaticPageTurnActive, saveReaderOptionsForBook, this, + saveGlobalSettingsForBookReader, this, beginGlobalSettingsEditForBookReader, this, endGlobalSettingsEditForBookReader, this, bookSettings.dictionarySdFontFamilyName, bookSettings.dictionaryFontPointSize, bookSettings.hasDictionaryFontOverride, saveDictionaryFontForBookReader, this, touchReaderDrawerState, std::move(buttonPreviewModel)); @@ -2726,7 +2766,8 @@ void EpubReaderActivity::openReaderMenu() { } if (menu->reopenDrawer && (action == EpubReaderMenuAction::BOOKMARK_TOGGLE || action == EpubReaderMenuAction::TOGGLE_COMPLETED || - action == EpubReaderMenuAction::RESET_READING_PACE)) { + action == EpubReaderMenuAction::RESET_READING_PACE || + action == EpubReaderMenuAction::TOGGLE_BOOK_STATS_TRACKING)) { openReaderMenu(); } } @@ -2912,6 +2953,7 @@ bool EpubReaderActivity::transientFeedbackDismissed(const unsigned long showTime } void EpubReaderActivity::loop() { + syncStatsTrackingState(); bool rawTouchInput = false; #if CROSSINK_APP_CAP_TOUCH int touchDownX = 0; @@ -2941,7 +2983,8 @@ void EpubReaderActivity::loop() { } #endif - const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); + const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput, + epub && ReaderUtils::isRtlBookLanguage(epub->getLanguage())); const int bottomTapHeight = automaticPageTurnActive ? std::max(UITheme::getStatusBarHeight(), @@ -3314,76 +3357,63 @@ void EpubReaderActivity::loop() { return; } - // Side button long-press actions use raw Up/Down so the direction stays - // physical regardless of the Prev/Next side layout setting. - const bool sideLongPressSkipsChapter = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_CHAPTER_SKIP; - const bool sideLongPressChangesFont = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_FONT_SIZE; - const bool sideLongPressChangesOrientation = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_ORIENTATION_CHANGE; - if (sideLongPressSkipsChapter || sideLongPressChangesFont || sideLongPressChangesOrientation) { - const bool topReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); - const bool bottomReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); - const bool sidePrevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack); - const bool sideNextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward); - const bool sideLongPressReleased = - sideLongPressSkipsChapter ? (sidePrevReleased || sideNextReleased) : (topReleased || bottomReleased); - if (sideButtonLongPressHandled && sideLongPressReleased) { - sideButtonLongPressHandled = false; - backgroundBuildYieldForInput.store(false, std::memory_order_relaxed); - return; - } - - const bool longPressReady = mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS; - const bool prevLongPressed = longPressReady && mappedInput.isPressed(MappedInputManager::Button::PageBack); - const bool nextLongPressed = longPressReady && mappedInput.isPressed(MappedInputManager::Button::PageForward); - const bool topLongPressed = - longPressReady && (mappedInput.isPressed(MappedInputManager::Button::Up) || topReleased); - const bool bottomLongPressed = - longPressReady && (mappedInput.isPressed(MappedInputManager::Button::Down) || bottomReleased); - - if (sideLongPressSkipsChapter && !sideButtonLongPressHandled && (prevLongPressed || nextLongPressed)) { - sideButtonLongPressHandled = true; - clearPendingManualPageTurns(); - if (!nextLongPressed && isAtBookStart()) return; - if (!nextLongPressed && section && section->currentPage > 0) { - section->currentPage = 0; - requestUpdate(); - return; - } - - // We don't want to delete the section mid-render, so grab the semaphore. - { - RenderLock lock(*this); - nextPageNumber = 0; - if (nextLongPressed) { - currentSpineIndex++; - } else if (currentSpineIndex > 0) { - currentSpineIndex--; + const bool chordPageTurn = mappedInput.hasInjectedRelease(MappedInputManager::Button::Right); + const auto side = sideButtonShortcuts.update(mappedInput, millis()); + if (SideButtonShortcuts::shouldConsume(side, mappedInput)) { + if (side.triggered) { + if (side.longPress) + mappedInput.suppressNextSideRelease(side.up ? MappedInputManager::Button::Up + : MappedInputManager::Button::Down); + switch (side.action) { + case CrossPointSettings::SIDE_PREVIOUS_CHAPTER: + case CrossPointSettings::SIDE_NEXT_CHAPTER: { + const bool next = side.action == CrossPointSettings::SIDE_NEXT_CHAPTER; + clearPendingManualPageTurns(); + if (!next && isAtBookStart()) break; + if (!next && section && section->currentPage > 0) { + section->currentPage = 0; + } else { + RenderLock lock(*this); + nextPageNumber = 0; + if (next) + currentSpineIndex++; + else if (currentSpineIndex > 0) + currentSpineIndex--; + section.reset(); + } + requestUpdate(); + break; } - section.reset(); - } - requestUpdate(); - return; - } - - if ((sideLongPressChangesFont || sideLongPressChangesOrientation) && !sideButtonLongPressHandled && - (topLongPressed || bottomLongPressed)) { - // Top grows the font and rotates counter-clockwise; bottom is its inverse. - // Top wins when both are held, matching the previous ordered checks. - const bool isTop = topLongPressed; - sideButtonLongPressHandled = !(isTop ? topReleased : bottomReleased); - if (sideLongPressChangesFont) { - if (ReaderUtils::changeReaderFontSizeWithFeedback(renderer, /*larger=*/isTop)) { - reindexCurrentSection(); + case CrossPointSettings::SIDE_INCREASE_FONT: + case CrossPointSettings::SIDE_DECREASE_FONT: + if (ReaderUtils::changeReaderFontSizeWithFeedback(renderer, + side.action == CrossPointSettings::SIDE_INCREASE_FONT)) + reindexCurrentSection(); + break; + case CrossPointSettings::SIDE_ROTATE_COUNTERCLOCKWISE: + case CrossPointSettings::SIDE_ROTATE_CLOCKWISE: + applyOrientation(ReaderUtils::rotatedOrientation(SETTINGS.orientation, + side.action == CrossPointSettings::SIDE_ROTATE_CLOCKWISE)); + requestUpdate(); + break; + case CrossPointSettings::IGNORE: + break; + default: { + const auto action = static_cast(side.action); + if (action == CrossPointSettings::QUICK_LOCK) + handleGlobalPowerButtonAction(action, SideButtonShortcuts::quickLockTrigger(side)); + else if (!handleShortcutAction(action)) + handleGlobalPowerButtonAction(action); + break; } - } else { - applyOrientation(ReaderUtils::rotatedOrientation(SETTINGS.orientation, /*clockwise=*/!isTop)); - requestUpdate(); } - return; } + // A consumed side release that queues no render must not leave indexing paused. + if (!side.triggered && !drainPendingManualPageTurn() && !mappedInput.isPressed(MappedInputManager::Button::Up) && + !mappedInput.isPressed(MappedInputManager::Button::Down)) { + backgroundBuildYieldForInput.store(false, std::memory_order_relaxed); + } + return; } if (consumeLongPowerButtonRelease()) { @@ -3534,9 +3564,7 @@ void EpubReaderActivity::loop() { const unsigned long heldMs = fromTouch ? touch.heldMs : mappedInput.getHeldTime(); const bool longPress = !fromTilt && heldMs > ReaderUtils::SKIP_HOLD_MS; const bool skipChapter = - !fromTouch && longPress && - (fromSideBtn ? SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_CHAPTER_SKIP - : SETTINGS.longPressButtonBehavior == CrossPointSettings::CHAPTER_SKIP); + !chordPageTurn && !fromTouch && longPress && SETTINGS.longPressButtonBehavior == CrossPointSettings::CHAPTER_SKIP; // Don't skip chapter after screenshot if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) { @@ -3567,7 +3595,7 @@ void EpubReaderActivity::loop() { return; } - if (!fromTouch && longPress && !fromSideBtn && + if (!chordPageTurn && !fromTouch && longPress && !fromSideBtn && SETTINGS.longPressButtonBehavior == CrossPointSettings::ORIENTATION_CHANGE) { const uint8_t newOrientation = nextTriggered ? (SETTINGS.orientation - 1 + SETTINGS.ORIENTATION_COUNT) % SETTINGS.ORIENTATION_COUNT @@ -3990,18 +4018,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuAction action, const break; } case EpubReaderMenuAction::FOOTNOTES: { - pauseReadingPaceTimer("footnotes"); - startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), - [this, returnToReaderMenu](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& footnoteResult = std::get(result.data); - navigateToHref(footnoteResult.href, true); - } else if (returnToReaderMenu) { - openReaderMenu(); - return; - } - requestUpdate(); - }); + openFootnoteSelect(returnToReaderMenu); break; } case EpubReaderMenuAction::GO_TO_PERCENT: { @@ -4128,6 +4145,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuAction action, const return; } case EpubReaderMenuAction::DELETE_STATS: { + if (!statsTrackingActive) break; pauseReadingPaceTimer("delete_stats_confirm"); startActivityForResult(std::make_unique(renderer, mappedInput, confirmationHeading(StrId::STR_DELETE_BOOK_STATS), @@ -4279,9 +4297,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuAction action, const break; } case EpubReaderMenuAction::READING_STATS: { + if (!statsTrackingActive) break; // Include elapsed time from the current session in the display stats. BookReadingStats displayStats = stats; - if (SETTINGS.shouldTrackReadingStats()) { + if (statsTrackingActive) { uint32_t currentPageSeconds = 0; displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - sessionReadingSeconds ? UINT32_MAX @@ -4314,6 +4333,18 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuAction action, const } break; } + case EpubReaderMenuAction::TOGGLE_BOOK_STATS_TRACKING: { + if (!epub || !SETTINGS.shouldTrackReadingStats()) break; + const bool enabled = !bookStatsEnabled; + if (!BookStatsTracking::setBookEnabled(epub->getCachePath(), enabled)) { + const std::string error = std::string(tr(STR_TRACK_READING_STATS)) + " " + tr(STR_FAILED_LOWER); + drawToast(renderer, error.c_str()); + } + bookStatsEnabled = BookStatsTracking::isBookEnabled(epub->getCachePath()); + syncStatsTrackingState(); + requestUpdate(); + break; + } case EpubReaderMenuAction::TOGGLE_COMPLETED: { const bool markCompleted = !stats.isCompleted; setBookCompleted(markCompleted); @@ -4578,10 +4609,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuAction action, const } std::unique_ptr EpubReaderActivity::createFrontlightReadingStatsActivity() { + if (!statsTrackingActive) return {}; if (!epub) return {}; BookReadingStats displayStats = stats; - if (SETTINGS.shouldTrackReadingStats()) { + if (statsTrackingActive) { displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - sessionReadingSeconds ? UINT32_MAX : displayStats.totalReadingSeconds + sessionReadingSeconds; @@ -5336,6 +5368,8 @@ bool EpubReaderActivity::handleShortcutAction(const uint8_t rawAction) { case CrossPointSettings::SHORT_PWRBTN::TOGGLE_TOUCHSCREEN: return handleGlobalPowerButtonAction(action); case CrossPointSettings::SHORT_PWRBTN::SLEEP: + case CrossPointSettings::SHORT_PWRBTN::SLEEP_ONLY: + case CrossPointSettings::SHORT_PWRBTN::WAKE_ONLY: case CrossPointSettings::SHORT_PWRBTN::FORCE_REFRESH: case CrossPointSettings::SHORT_PWRBTN::SHORT_PWRBTN_COUNT: return false; @@ -5445,6 +5479,9 @@ void EpubReaderActivity::openQuickActionsPopup() { return; } dispatchShortcutAction(action); + }, + [this](const auto action) { + return action != CrossPointSettings::SHORT_PWRBTN::READING_STATS || statsTrackingActive; }); if (quickActionsPopup.isActive()) { mappedInput.setReaderTouchscreenOverride(true); @@ -5452,36 +5489,96 @@ void EpubReaderActivity::openQuickActionsPopup() { } } -void EpubReaderActivity::executeFootnoteQuickAction(const bool suppressInitialPowerRelease) { - clearPendingManualPageTurns(); - if (footnoteDepth > 0 && SETTINGS.pwrBtnFootnoteBack) { - restoreSavedPosition(); - return; - } - +void EpubReaderActivity::openFootnoteSelect(const bool returnToReaderMenu) { + if (currentPageFootnotes.empty()) return; if (currentPageFootnotes.size() == 1) { navigateToHref(currentPageFootnotes[0].href, true); + requestUpdate(); return; } - if (currentPageFootnotes.size() > 1) { - if (suppressInitialPowerRelease) { - suppressPowerShortcutRelease(); + std::unique_ptr page; + FootnoteLinkTargets targets{}; + ReaderViewportLayout layout{}; + const int fontId = activeSectionFontId != 0 ? activeSectionFontId : SETTINGS.getReaderFontId(); + { + RenderLock lock(*this); + if (section) { + page = section->loadPageFromSectionFile(); + if (page) { + layout = computeReaderViewportLayout(renderer, automaticPageTurnActive); + targets = + buildFootnoteLinkTargets(*page, page->footnotes, renderer, fontId, layout.marginTop, layout.marginLeft); + } + } + } + + pauseReadingPaceTimer("footnotes"); + const auto onResult = [this, returnToReaderMenu](const ActivityResult& result) { + if (result.isCancelled) { + if (returnToReaderMenu) { + openReaderMenu(); + } else { + resumeReadingPaceTimer("footnotes_cancel"); + requestUpdate(); + } + return; + } + const auto* selected = std::get_if(&result.data); + if (selected) { + navigateToHref(selected->href, true); + } else { + resumeReadingPaceTimer("footnotes_invalid_result"); + } + requestUpdate(); + }; + + bool allTargetsVisible = + page && page->footnotes.size() == currentPageFootnotes.size() && page->footnotes.size() <= targets.size(); + if (allTargetsVisible) { + for (size_t i = 0; i < page->footnotes.size(); ++i) { + if (targets[i].width <= 0 || targets[i].height <= 0) { + allTargetsVisible = false; + break; + } + } + } + if (allTargetsVisible) { + auto selector = makeUniqueNoThrow(renderer, mappedInput, std::move(page), targets, + fontId, layout.marginLeft, layout.marginTop); + if (selector) { + startActivityForResult(std::move(selector), onResult); + return; + } + LOG_ERR("FNS", "OOM allocating footnote selector; falling back to list"); + } + + // Older or malformed page records can lack a drawable link ID. Keep every + // destination reachable through the existing list in that case. + auto list = makeUniqueNoThrow(renderer, mappedInput, currentPageFootnotes); + if (list) { + startActivityForResult(std::move(list), onResult); + } else { + LOG_ERR("FNS", "OOM allocating footnote fallback list"); + if (returnToReaderMenu) { + openReaderMenu(); + } else { + resumeReadingPaceTimer("footnotes_alloc_failed"); + requestUpdate(); } - pauseReadingPaceTimer("footnotes"); - startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& footnoteResult = std::get(result.data); - navigateToHref(footnoteResult.href, true); - } else { - resumeReadingPaceTimer("footnotes_cancel"); - } - requestUpdate(); - }); } } +void EpubReaderActivity::executeFootnoteQuickAction(const bool suppressInitialPowerRelease) { + clearPendingManualPageTurns(); + if (footnoteDepth > 0 && SETTINGS.pwrBtnFootnoteBack) { + restoreSavedPosition(); + return; + } + if (currentPageFootnotes.size() > 1 && suppressInitialPowerRelease) suppressPowerShortcutRelease(); + openFootnoteSelect(false); +} + bool EpubReaderActivity::executeShortPowerButtonAction() { if (!mappedInput.wasReleased(MappedInputManager::Button::Power) || mappedInput.getHeldTime() >= SETTINGS.getPowerButtonLongPressDuration()) { @@ -5691,7 +5788,7 @@ void EpubReaderActivity::setBookCompleted(bool isCompleted) { } stats.isCompleted = isCompleted; - if (isCompleted && !stats.finishedDateManual) { + if (isCompleted && statsTrackingActive && !stats.finishedDateManual) { ReadingStatsDateTime now; if (getCurrentLocalReadingStatsDateTime(now)) { stats.finishedDate = now.date; @@ -5712,15 +5809,16 @@ void EpubReaderActivity::setBookCompleted(bool isCompleted) { recentsEntryRemoved = false; pendingReadFolderMove = false; } - if (isCompleted) { - globalStats.completedBooks++; - } else if (globalStats.completedBooks > 0) { - globalStats.completedBooks--; - } - refreshCachedTimeLeftEstimate(); - stats.save(epub->getCachePath()); - globalStats.save(); + if (!stats.save(epub->getCachePath())) return; + if (statsTrackingActive) { + if (isCompleted) { + globalStats.completedBooks++; + } else if (globalStats.completedBooks > 0) { + globalStats.completedBooks--; + } + globalStats.save(); + } } void EpubReaderActivity::showCompletedFeedback(bool isCompleted) { @@ -6027,8 +6125,10 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn, const char* source) { if (!exitingChapter) { recordForwardPagePaceSample(forwardReadSeconds, source); } - stats.totalPagesTurned++; - globalStats.totalPagesTurned++; + if (statsTrackingActive) { + stats.totalPagesTurned++; + globalStats.totalPagesTurned++; + } } } else { if (isAtBookStart()) { @@ -7767,56 +7867,9 @@ bool EpubReaderActivity::renderContents(std::unique_ptr page, const int fo void EpubReaderActivity::buildFootnoteTouchTargets(const Page& page, const int fontId, const int orientedMarginTop, const int orientedMarginLeft) { currentPageFootnoteTouchTargets.fill({}); - if (activeFootnotePreview || currentPageFootnotes.empty()) { - return; - } - - const int lineHeight = renderer.getLineHeight(fontId); - for (const auto& element : page.elements) { - if (!element || element->getTag() != TAG_PageLine) continue; - const auto& line = static_cast(*element); - if (!line.getBlock()) continue; - - const auto& block = *line.getBlock(); - for (uint16_t wordIndex = 0; wordIndex < block.wordCount(); ++wordIndex) { - const uint8_t linkId = block.wordLinkId(wordIndex); - if (linkId == 0) continue; - - const auto footnoteIt = std::find_if(currentPageFootnotes.begin(), currentPageFootnotes.end(), - [linkId](const FootnoteEntry& entry) { return entry.linkId == linkId; }); - if (footnoteIt == currentPageFootnotes.end()) continue; - const size_t footnoteIndex = static_cast(footnoteIt - currentPageFootnotes.begin()); - if (footnoteIndex >= currentPageFootnoteTouchTargets.size()) continue; - - const auto style = static_cast(block.wordStyle(wordIndex) & ~EpdFontFamily::UNDERLINE); - const int wordX = orientedMarginLeft + line.xPos + block.wordXpos(wordIndex); - int wordY = orientedMarginTop + line.yPos; - if ((style & EpdFontFamily::SUP) != 0) { - wordY -= renderer.getFontAscenderSize(fontId) * 2 / 5; - } else if ((style & EpdFontFamily::SUB) != 0) { - wordY += renderer.getFontAscenderSize(fontId) / 4; - } - int wordWidth = renderer.getTextAdvanceX(fontId, block.wordText(wordIndex), style); - if (wordIndex + 1 < block.wordCount() && block.wordXpos(wordIndex + 1) > block.wordXpos(wordIndex)) { - wordWidth = std::min(wordWidth, static_cast(block.wordXpos(wordIndex + 1) - block.wordXpos(wordIndex))); - } - if (wordWidth <= 0) continue; - - auto& target = currentPageFootnoteTouchTargets[footnoteIndex]; - if (target.width <= 0 || target.height <= 0) { - target = {static_cast(wordX), static_cast(wordY), static_cast(wordWidth), - static_cast(lineHeight)}; - continue; - } - - const int left = std::min(target.x, wordX); - const int top = std::min(target.y, wordY); - const int right = std::max(target.x + target.width, wordX + wordWidth); - const int bottom = std::max(target.y + target.height, wordY + lineHeight); - target = {static_cast(left), static_cast(top), static_cast(right - left), - static_cast(bottom - top)}; - } - } + if (activeFootnotePreview || currentPageFootnotes.empty()) return; + currentPageFootnoteTouchTargets = + buildFootnoteLinkTargets(page, currentPageFootnotes, renderer, fontId, orientedMarginTop, orientedMarginLeft); } bool EpubReaderActivity::handleTouchFootnoteLink(const int touchX, const int touchY) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 7c978f00be1..d4d639118f7 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -17,9 +17,11 @@ #include "BookmarkStore.h" #include "EndOfBookOptions.h" #include "EpubReaderMenuModel.h" +#include "FootnoteLinkTargets.h" #include "GlobalReadingStats.h" #include "ManualPageTurnQueue.h" #include "ReaderProgressSaveDebouncer.h" +#include "SideButtonShortcuts.h" #include "activities/Activity.h" #include "activities/reader/TouchReaderPreviewModel.h" #include "components/OptionPopup.h" @@ -188,8 +190,13 @@ class EpubReaderActivity final : public Activity { ReaderSettingsSnapshot suspendedBookReaderSettings; BookReadingStats stats; GlobalReadingStats globalStats; + bool bookStatsEnabled = true; + bool statsTrackingActive = true; + bool paceDirty = false; + bool pendingStatsCommit = false; ReadingStatsDateTime sessionStartLocalDateTime; bool hasSessionStartLocalDateTime = false; + void syncStatsTrackingState(); // Signals that the next render should reposition within the newly loaded section // based on a cross-book percentage jump. bool pendingPercentJump = false; @@ -216,7 +223,7 @@ class EpubReaderActivity final : public Activity { bool longPressBackHandled = false; bool longPowerButtonHandled = false; OptionPopup quickActionsPopup; - bool sideButtonLongPressHandled = false; + SideButtonShortcuts sideButtonShortcuts; bool frontButtonLongPressHandled = false; bool touchDictionaryLookupHandled = false; int pageLoadRetryCount = 0; @@ -268,13 +275,7 @@ class EpubReaderActivity final : public Activity { std::vector currentPageFootnotes; #if CROSSINK_APP_CAP_TOUCH ReaderPinchGesture pinchFontGesture; - struct FootnoteTouchTarget { - int16_t x = 0; - int16_t y = 0; - int16_t width = 0; - int16_t height = 0; - }; - std::array currentPageFootnoteTouchTargets{}; + FootnoteLinkTargets currentPageFootnoteTouchTargets{}; #endif struct SavedPosition { int spineIndex; @@ -438,6 +439,7 @@ class EpubReaderActivity final : public Activity { QuickLockTrigger quickLockTrigger = QuickLockTrigger::LongMenu); void openQuickActionsPopup(); void executeFootnoteQuickAction(bool suppressInitialPowerRelease = false); + void openFootnoteSelect(bool returnToReaderMenu); #if CROSSINK_APP_CAP_TOUCH bool handlePinchFontResize(); void resetPinchFontGesture(); diff --git a/src/activities/reader/EpubReaderDrawerActivity.cpp b/src/activities/reader/EpubReaderDrawerActivity.cpp index eda0764bba8..cc42ca0e29a 100644 --- a/src/activities/reader/EpubReaderDrawerActivity.cpp +++ b/src/activities/reader/EpubReaderDrawerActivity.cpp @@ -378,8 +378,9 @@ EpubReaderDrawerActivity::EpubReaderDrawerActivity( 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, + const bool isBookCompleted, const bool showReadingPaceReset, const bool globalStatsEnabled, + const bool bookStatsEnabled, const uint32_t stableCurrentPage, const uint32_t stablePageCount, + const uint16_t autoPageTurnIntervalSeconds, const bool automaticPageTurnActive, ReaderOptionsActivity::SaveSettingsCallback saveReaderSettingsCallback, void* saveReaderSettingsContext, ReaderOptionsActivity::SaveGlobalSettingsCallback saveGlobalSettingsCallback, void* saveGlobalSettingsContext, ReaderOptionsActivity::GlobalSettingsEditCallback beginGlobalSettingsEditCallback, @@ -408,6 +409,8 @@ EpubReaderDrawerActivity::EpubReaderDrawerActivity( hasDictionary(hasDictionary), hasBookmarks(hasBookmarks), hasClippings(hasClippings), + globalStatsEnabled(globalStatsEnabled), + bookStatsEnabled(bookStatsEnabled), isCurrentPageBookmarked(isCurrentPageBookmarked), isBookCompleted(isBookCompleted), showReadingPaceReset(showReadingPaceReset), @@ -445,9 +448,9 @@ void EpubReaderDrawerActivity::onEnter() { Activity::onEnter(); if (mappedInput.hasTouchHardware()) mappedInput.setReaderTouchscreenOverride(true); - const ReaderDrawerCatalog catalog = - makeReaderDrawerCatalog({hasFootnotes, hasDictionary, hasBookmarks, hasClippings, showReadingPaceReset, - stablePageCount > 0, !mappedInput.hasTouchHardware()}); + const ReaderDrawerCatalog catalog = makeReaderDrawerCatalog( + {hasFootnotes, hasDictionary, hasBookmarks, hasClippings, showReadingPaceReset, stablePageCount > 0, + !mappedInput.hasTouchHardware(), globalStatsEnabled, bookStatsEnabled}); 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); @@ -871,10 +874,12 @@ void EpubReaderDrawerActivity::buildDrawer(UiApp::ScreenType& screen) { const fui::Rect sheetContent = screen.sheet(sheet, drawerHeight()); drawerHandleRect = DrawerHandle::registerTap(screen.frame(), sheetContent, sheet, ACTION_DISMISS); } + int16_t buttonHeaderHeight = 0; if (buttonDevice) { const auto& metrics = UITheme::getInstance().getMetrics(); - screen.takeTop(static_cast(metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + - metrics.tabBarHeight)); + buttonHeaderHeight = static_cast(metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + + metrics.tabBarHeight); + screen.takeTop(buttonHeaderHeight); } // 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 @@ -885,7 +890,11 @@ void EpubReaderDrawerActivity::buildDrawer(UiApp::ScreenType& screen) { // 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; + // In portrait, keep the sample at its pre-header height so the new book + // progress row does not remove a line. Landscape needs room for its rows. + const int previewBaseHeight = + screen.body().height + (isLandscapeOrientation(renderer.getOrientation()) ? 0 : buttonHeaderHeight); + int previewHeight = previewBaseHeight * metrics.previewHeightPercent / 100; if (readerDrawerSliderPreviewsText(state.pane)) { // Leave both controls and both translated help lines usable in landscape. ReaderSliderRowProps row; @@ -1755,6 +1764,9 @@ void EpubReaderDrawerActivity::activateRow(const RowId row) { isBookCompleted = !isBookCompleted; closeAndReturn(false, EpubReaderMenuAction::TOGGLE_COMPLETED); return; + case RowId::TrackBookStats: + closeAndReturn(false, EpubReaderMenuAction::TOGGLE_BOOK_STATS_TRACKING, true); + return; case RowId::DeleteBookmarks: closeAndReturn(false, EpubReaderMenuAction::DELETE_BOOKMARKS); return; @@ -2968,6 +2980,8 @@ const char* EpubReaderDrawerActivity::rowLabel(const RowId row) const { return tr(STR_INDEXING_METHOD); case RowId::ToggleCompleted: return isBookCompleted ? tr(STR_MARK_UNFINISHED) : tr(STR_MARK_FINISHED); + case RowId::TrackBookStats: + return tr(STR_TRACK_READING_STATS); case RowId::Controls: return tr(STR_CAT_CONTROLS); case RowId::ResetReadingPace: @@ -3101,6 +3115,7 @@ const char* EpubReaderDrawerActivity::rowValue(const RowId row, char* buffer, co bool EpubReaderDrawerActivity::rowIsToggle(const RowId row) { switch (row) { + case RowId::TrackBookStats: case RowId::TextAa: case RowId::Focus: case RowId::GuideDots: @@ -3145,6 +3160,8 @@ bool EpubReaderDrawerActivity::rowShowsNavigationCaret(const RowId row) const { bool EpubReaderDrawerActivity::rowToggleValue(const RowId row) const { switch (row) { + case RowId::TrackBookStats: + return bookStatsEnabled; case RowId::TextAa: return draft.textAntiAliasing; case RowId::Focus: diff --git a/src/activities/reader/EpubReaderDrawerActivity.h b/src/activities/reader/EpubReaderDrawerActivity.h index ab41f41cf4f..a40b7ac10a0 100644 --- a/src/activities/reader/EpubReaderDrawerActivity.h +++ b/src/activities/reader/EpubReaderDrawerActivity.h @@ -30,8 +30,8 @@ class EpubReaderDrawerActivity final : public Activity { 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, + bool showReadingPaceReset, bool globalStatsEnabled, bool bookStatsEnabled, uint32_t stableCurrentPage, + uint32_t stablePageCount, uint16_t autoPageTurnIntervalSeconds, bool automaticPageTurnActive, ReaderOptionsActivity::SaveSettingsCallback saveReaderSettingsCallback = nullptr, void* saveReaderSettingsContext = nullptr, ReaderOptionsActivity::SaveGlobalSettingsCallback saveGlobalSettingsCallback = nullptr, @@ -120,6 +120,8 @@ class EpubReaderDrawerActivity final : public Activity { bool hasDictionary = false; bool hasBookmarks = false; bool hasClippings = false; + bool globalStatsEnabled = true; + bool bookStatsEnabled = true; bool isCurrentPageBookmarked = false; bool isBookCompleted = false; bool showReadingPaceReset = false; diff --git a/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp b/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp new file mode 100644 index 00000000000..d7267967d82 --- /dev/null +++ b/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp @@ -0,0 +1,192 @@ +#include "EpubReaderFootnoteSelectActivity.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "MappedInputManager.h" +#include "ReaderUtils.h" +#include "components/TouchHeaderBackButton.h" +#include "components/UITheme.h" +#include "util/InputReleaseGuard.h" + +void EpubReaderFootnoteSelectActivity::onEnter() { + Activity::onEnter(); + ignoreInitialBackRelease = mappedInput.isPressed(MappedInputManager::Button::Back); + ignoreInitialConfirmRelease = mappedInput.isPressed(MappedInputManager::Button::Confirm); + ignoreInitialPowerRelease = mappedInput.isPressed(MappedInputManager::Button::Power); + for (size_t i = 0; i < page->footnotes.size() && i < targets.size(); ++i) { + if (targets[i].width > 0 && targets[i].height > 0) selectable[selectableCount++] = static_cast(i); + } + snapshot = makeUniqueNoThrow(SNAPSHOT_CAPACITY); + if (!snapshot) LOG_ERR("FNS", "OOM allocating footnote highlight snapshot; using full repaint"); + requestUpdate(); +} + +void EpubReaderFootnoteSelectActivity::cancel() { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); +} + +void EpubReaderFootnoteSelectActivity::performJump() { + if (selected >= selectableCount) return; + setResult(FootnoteResult{page->footnotes[selectable[selected]].href}); + finish(); +} + +void EpubReaderFootnoteSelectActivity::loop() { + if (InputReleaseGuard::consumeInitialRelease(mappedInput, MappedInputManager::Button::Back, + ignoreInitialBackRelease) || + InputReleaseGuard::consumeInitialRelease(mappedInput, MappedInputManager::Button::Power, + ignoreInitialPowerRelease) || + InputReleaseGuard::consumeInitialRelease(mappedInput, MappedInputManager::Button::Confirm, + ignoreInitialConfirmRelease)) { + return; + } + + if (TouchHeaderBackButton::wasTapped(mappedInput, renderer) || + mappedInput.wasReleased(MappedInputManager::Button::Back)) { + cancel(); + return; + } + + int touchX = 0; + int touchY = 0; + if (mappedInput.hasTouch() && mappedInput.wasScreenTapped(touchX, touchY)) { + int bestIndex = -1; + int64_t bestDistance = INT64_MAX; + for (uint8_t i = 0; i < selectableCount; ++i) { + const auto& target = targets[selectable[i]]; + if (touchX < target.x - 4 || touchX >= target.x + target.width + 4 || touchY < target.y - 4 || + touchY >= target.y + target.height + 4) { + continue; + } + const int64_t dx = static_cast(touchX) - (target.x + target.width / 2); + const int64_t dy = static_cast(touchY) - (target.y + target.height / 2); + const int64_t distance = dx * dx + dy * dy; + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = i; + } + } + if (bestIndex >= 0) { + selected = static_cast(bestIndex); + performJump(); + return; + } + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || + mappedInput.wasReleased(MappedInputManager::Button::Power)) { + performJump(); + return; + } + if (selectableCount == 0) return; + + const unsigned long now = millis(); + const bool repeat = mappedInput.getHeldTime() >= REPEAT_START_MS && now - lastMoveTime >= REPEAT_INTERVAL_MS; + const bool left = mappedInput.wasPressed(MappedInputManager::Button::Left) || + (repeat && mappedInput.isPressed(MappedInputManager::Button::Left)); + const bool right = mappedInput.wasPressed(MappedInputManager::Button::Right) || + (repeat && mappedInput.isPressed(MappedInputManager::Button::Right)); + if ((left || mappedInput.wasPressed(MappedInputManager::Button::Up)) && selected > 0) { + --selected; + lastMoveTime = now; + requestUpdate(); + } else if ((right || mappedInput.wasPressed(MappedInputManager::Button::Down)) && selected + 1 < selectableCount) { + ++selected; + lastMoveTime = now; + requestUpdate(); + } +} + +bool EpubReaderFootnoteSelectActivity::drawHighlightWithSnapshot() { + if (selected >= selectableCount) return false; + const auto& target = targets[selectable[selected]]; + const int screenW = renderer.getScreenWidth(); + const int screenH = renderer.getScreenHeight(); + const int x = std::max(0, static_cast(target.x) - 2); + const int y = std::max(0, static_cast(target.y) - 2); + const int w = std::min(screenW, static_cast(target.x) + target.width + 2) - x; + const int h = std::min(screenH, static_cast(target.y) + target.height + 2) - y; + if (w <= 0 || h <= 0) return false; + + const bool saved = snapshot && renderer.readFramebufferRegion(x, y, w, h, snapshot.get(), SNAPSHOT_CAPACITY) > 0; + snapshotX = static_cast(x); + snapshotY = static_cast(y); + snapshotW = static_cast(w); + snapshotH = static_cast(h); + snapshotIdx = saved ? selected : -1; + + // Invert the already rendered marker. This preserves SD-font glyph pixels + // without trying to rasterize text after the page prewarm scope has ended. + renderer.invertRect(x, y, w, h); + return saved; +} + +void EpubReaderFootnoteSelectActivity::drawChrome() const { + if (mappedInput.hasTouchHardware()) { + TouchHeaderBackButton::draw(renderer, TouchHeaderBackButton::headerRect(renderer, mappedInput), tr(STR_FOOTNOTES), + true); + } + const auto labels = mappedInput.mapLabels(mappedInput.withBackArrow(tr(STR_BACK)), tr(STR_SELECT), "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4, true); +} + +void EpubReaderFootnoteSelectActivity::render(RenderLock&&) { + if (snapshotIdx >= 0 && selected != snapshotIdx) { + renderer.writeFramebufferRegion(snapshotX, snapshotY, snapshotW, snapshotH, snapshot.get()); + if (drawHighlightWithSnapshot()) { + drawChrome(); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + return; + } + } + + snapshotIdx = -1; + // The selector repaints the page after the reader has paused. SD font glyphs + // may have been evicted, so use the same scan and prewarm sequence as reading. + std::optional pageRenderScope; + if (auto* fcm = renderer.getFontCacheManager()) { + const auto prewarmVisibleText = [&]() { + pageRenderScope.emplace(*fcm, FontCacheManager::PreparationPolicy::Normal); + page->renderText(renderer, fontId, marginLeft, marginTop); + drawChrome(); + if (!pageRenderScope->endScanAndPrewarm()) { + pageRenderScope.reset(); + return false; + } + return true; + }; + + bool prewarmSucceeded = prewarmVisibleText(); + if (!prewarmSucceeded && renderer.isSdCardFont(fontId)) { + LOG_ERR("FNS", "SD-font page prewarm failed (font=%d free=%u maxAlloc=%u); releasing caches and retrying", fontId, + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + renderer.releaseSdCardFontForLowMemory(fontId, /*preserveAdvanceTable=*/true); + prewarmSucceeded = prewarmVisibleText(); + } + if (!prewarmSucceeded) { + LOG_ERR("FNS", "Font page prewarm failed after retry (font=%d free=%u maxAlloc=%u)", fontId, ESP.getFreeHeap(), + ESP.getMaxAllocHeap()); + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); + GUI.drawPopup(renderer, tr(STR_MEMORY_ERROR)); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + return; + } + } + + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); + page->render(renderer, fontId, marginLeft, marginTop, ReaderUtils::readerForegroundBlack()); + drawHighlightWithSnapshot(); + drawChrome(); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); +} diff --git a/src/activities/reader/EpubReaderFootnoteSelectActivity.h b/src/activities/reader/EpubReaderFootnoteSelectActivity.h new file mode 100644 index 00000000000..c8503708dd6 --- /dev/null +++ b/src/activities/reader/EpubReaderFootnoteSelectActivity.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +#include +#include + +#include "FootnoteLinkTargets.h" +#include "activities/Activity.h" + +// Select a footnote reference on the page itself. The page is owned by this +// activity so the source reader can remain paused until a jump or cancellation. +class EpubReaderFootnoteSelectActivity final : public Activity { + public: + EpubReaderFootnoteSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr page, + FootnoteLinkTargets targets, int fontId, int marginLeft, int marginTop) + : Activity("EpubReaderFootnoteSelect", renderer, mappedInput), + page(std::move(page)), + targets(targets), + fontId(fontId), + marginLeft(marginLeft), + marginTop(marginTop) {} + + bool isReaderActivity() const override { return true; } + bool allowPowerAsConfirmInReaderMode() const override { return true; } + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; + + private: + static constexpr size_t SNAPSHOT_CAPACITY = 4096; + static constexpr unsigned long REPEAT_START_MS = 500; + static constexpr unsigned long REPEAT_INTERVAL_MS = 500; + + void cancel(); + void performJump(); + bool drawHighlightWithSnapshot(); + void drawChrome() const; + + std::unique_ptr page; + FootnoteLinkTargets targets; + const int fontId; + const int marginLeft; + const int marginTop; + std::array selectable{}; + uint8_t selectableCount = 0; + uint8_t selected = 0; + std::unique_ptr snapshot; + int16_t snapshotX = 0; + int16_t snapshotY = 0; + int16_t snapshotW = 0; + int16_t snapshotH = 0; + int snapshotIdx = -1; + unsigned long lastMoveTime = 0; + bool ignoreInitialBackRelease = false; + bool ignoreInitialConfirmRelease = false; + bool ignoreInitialPowerRelease = false; +}; diff --git a/src/activities/reader/EpubReaderMenuModel.h b/src/activities/reader/EpubReaderMenuModel.h index e376aa85b2b..246e3da951f 100644 --- a/src/activities/reader/EpubReaderMenuModel.h +++ b/src/activities/reader/EpubReaderMenuModel.h @@ -21,6 +21,7 @@ enum class EpubReaderMenuAction : uint8_t { DELETE_CACHE, RESET_READING_PACE, READING_STATS, + TOGGLE_BOOK_STATS_TRACKING, TOGGLE_COMPLETED, READER_OPTIONS, CONTROLS_OPTIONS, @@ -145,6 +146,7 @@ enum class ReaderDrawerCatalogItem : uint8_t { TtfReset, ResetBookReaderSettings, ReadingStats, + TrackBookStats, SyncProgress, NearbyPositionSync, SendNearbyBook, @@ -158,6 +160,8 @@ struct ReaderDrawerAvailability { bool showReadingPaceReset = false; bool hasStablePageNumbers = false; bool buttonDevice = false; + bool globalStatsEnabled = true; + bool bookStatsEnabled = true; }; struct ReaderDrawerTabCatalog { @@ -199,7 +203,8 @@ 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.buttonDevice && available.globalStatsEnabled && available.bookStatsEnabled) + more.add(ReaderDrawerCatalogItem::ReadingStats); if (available.hasFootnotes) more.add(ReaderDrawerCatalogItem::Footnotes); auto& location = catalog[static_cast(ReaderDrawerTab::Location)]; @@ -225,9 +230,10 @@ constexpr ReaderDrawerCatalog makeReaderDrawerCatalog(const ReaderDrawerAvailabi settings.add(ReaderDrawerCatalogItem::RenderMode); settings.add(ReaderDrawerCatalogItem::IndexingMethod); settings.add(ReaderDrawerCatalogItem::ToggleCompleted); + if (available.globalStatsEnabled) settings.add(ReaderDrawerCatalogItem::TrackBookStats); if (available.showReadingPaceReset) settings.add(ReaderDrawerCatalogItem::ResetReadingPace); settings.add(ReaderDrawerCatalogItem::DeleteCache); - settings.add(ReaderDrawerCatalogItem::DeleteStats); + if (available.globalStatsEnabled && available.bookStatsEnabled) settings.add(ReaderDrawerCatalogItem::DeleteStats); settings.add(ReaderDrawerCatalogItem::ResetBookReaderSettings); return catalog; } diff --git a/src/activities/reader/FootnoteLinkTargets.cpp b/src/activities/reader/FootnoteLinkTargets.cpp new file mode 100644 index 00000000000..ca3a6971d2b --- /dev/null +++ b/src/activities/reader/FootnoteLinkTargets.cpp @@ -0,0 +1,61 @@ +#include "FootnoteLinkTargets.h" + +#include +#include + +#include + +FootnoteLinkTargets buildFootnoteLinkTargets(const Page& page, const std::vector& footnotes, + const GfxRenderer& renderer, const int fontId, const int marginTop, + const int marginLeft) { + FootnoteLinkTargets targets{}; + if (footnotes.empty()) return targets; + + const int lineHeight = renderer.getLineHeight(fontId); + for (const auto& element : page.elements) { + if (!element || element->getTag() != TAG_PageLine) continue; + const auto& line = static_cast(*element); + if (!line.getBlock()) continue; + + const auto& block = *line.getBlock(); + for (uint16_t wordIndex = 0; wordIndex < block.wordCount(); ++wordIndex) { + const uint8_t linkId = block.wordLinkId(wordIndex); + if (linkId == 0) continue; + + const auto footnoteIt = std::find_if(footnotes.begin(), footnotes.end(), + [linkId](const FootnoteEntry& entry) { return entry.linkId == linkId; }); + if (footnoteIt == footnotes.end()) continue; + const size_t footnoteIndex = static_cast(footnoteIt - footnotes.begin()); + if (footnoteIndex >= targets.size()) continue; + + const auto style = static_cast(block.wordStyle(wordIndex) & ~EpdFontFamily::UNDERLINE); + const int wordX = marginLeft + line.xPos + block.wordXpos(wordIndex); + int wordY = marginTop + line.yPos; + if ((style & EpdFontFamily::SUP) != 0) { + wordY -= renderer.getFontAscenderSize(fontId) * 2 / 5; + } else if ((style & EpdFontFamily::SUB) != 0) { + wordY += renderer.getFontAscenderSize(fontId) / 4; + } + int wordWidth = renderer.getTextAdvanceX(fontId, block.wordText(wordIndex), style); + if (wordIndex + 1 < block.wordCount() && block.wordXpos(wordIndex + 1) > block.wordXpos(wordIndex)) { + wordWidth = std::min(wordWidth, static_cast(block.wordXpos(wordIndex + 1) - block.wordXpos(wordIndex))); + } + if (wordWidth <= 0) continue; + + auto& target = targets[footnoteIndex]; + if (target.width <= 0 || target.height <= 0) { + target = {static_cast(wordX), static_cast(wordY), static_cast(wordWidth), + static_cast(lineHeight)}; + continue; + } + + const int left = std::min(target.x, wordX); + const int top = std::min(target.y, wordY); + const int right = std::max(target.x + target.width, wordX + wordWidth); + const int bottom = std::max(target.y + target.height, wordY + lineHeight); + target = {static_cast(left), static_cast(top), static_cast(right - left), + static_cast(bottom - top)}; + } + } + return targets; +} diff --git a/src/activities/reader/FootnoteLinkTargets.h b/src/activities/reader/FootnoteLinkTargets.h new file mode 100644 index 00000000000..11a1df4b888 --- /dev/null +++ b/src/activities/reader/FootnoteLinkTargets.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include +#include +#include + +class GfxRenderer; +class Page; + +struct FootnoteLinkTarget { + int16_t x = 0; + int16_t y = 0; + int16_t width = 0; + int16_t height = 0; +}; + +using FootnoteLinkTargets = std::array; + +// Match serialized link IDs to visible words without a heap allocation. +FootnoteLinkTargets buildFootnoteLinkTargets(const Page& page, const std::vector& footnotes, + const GfxRenderer& renderer, int fontId, int marginTop, int marginLeft); diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 11b669d8890..8f7027b7f89 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -7,6 +7,8 @@ #include #include +#include +#include #include "GlobalActions.h" #include "MappedInputManager.h" @@ -24,6 +26,14 @@ constexpr uint8_t STATUS_BAR_TEXT_PADDING = 3; // Gap between the top reader bar and the first line of book text. constexpr int8_t TOP_STATUS_BAR_TEXT_PADDING = 0; +inline bool isRtlBookLanguage(std::string_view tag) { + if (tag.size() < 2 || (tag.size() > 2 && tag[2] != '-' && tag[2] != '_')) return false; + const auto first = std::tolower(static_cast(tag[0])); + const auto second = std::tolower(static_cast(tag[1])); + return (first == 'h' && second == 'e') || (first == 'i' && second == 'w') || (first == 'a' && second == 'r') || + (first == 'f' && second == 'a'); +} + inline GfxRenderer::Orientation toRendererOrientation(const uint8_t orientation) { switch (orientation) { case CrossPointSettings::ORIENTATION::PORTRAIT: @@ -108,10 +118,12 @@ struct TouchPageTurn { unsigned long heldMs; }; -inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const MappedInputManager& input) { +inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const MappedInputManager& input, + const bool rtlBook = false) { #if !CROSSINK_APP_CAP_TOUCH (void)renderer; (void)input; + (void)rtlBook; return {false, false, false, 0, 0, 0}; #else TouchPageTurn result{false, false, false, 0, 0, 0}; @@ -134,8 +146,10 @@ inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const Mapp const auto swipe = input.wasSwipe(); if (swipe != MappedInputManager::SwipeDir::None) { - result.prev = swipe == MappedInputManager::SwipeDir::Right && allowsSwipe(SETTINGS.previousPageGesture); - result.next = swipe == MappedInputManager::SwipeDir::Left && allowsSwipe(SETTINGS.pageTurnGesture); + result.prev = swipe == (rtlBook ? MappedInputManager::SwipeDir::Left : MappedInputManager::SwipeDir::Right) && + allowsSwipe(SETTINGS.previousPageGesture); + result.next = swipe == (rtlBook ? MappedInputManager::SwipeDir::Right : MappedInputManager::SwipeDir::Left) && + allowsSwipe(SETTINGS.pageTurnGesture); return result; } @@ -155,11 +169,11 @@ inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const Mapp } // Give the entire page tap area to the sole tap-enabled direction. When - // both accept taps, either Inverted Tap setting swaps their shared zones. + // both accept taps, RTL books and Inverted Tap each swap their shared zones. const bool nextTaps = allowsTap(SETTINGS.pageTurnGesture); const bool previousTaps = allowsTap(SETTINGS.previousPageGesture); - const bool invertedTaps = SETTINGS.pageTurnGesture == CrossPointSettings::INVERTED_TAP || - SETTINGS.previousPageGesture == CrossPointSettings::INVERTED_TAP; + const bool invertedTaps = (SETTINGS.pageTurnGesture == CrossPointSettings::INVERTED_TAP || + SETTINGS.previousPageGesture == CrossPointSettings::INVERTED_TAP) != rtlBook; const bool nextZone = invertedTaps ? x < (width * 2) / 3 : x >= width / 3; result.next = nextTaps && (!previousTaps || nextZone); result.prev = previousTaps && (!nextTaps || !nextZone); @@ -196,15 +210,8 @@ inline bool isTouchMenuDismissGesture(const MappedInputManager& input) { } inline PageTurnResult detectPageTurn(const MappedInputManager& input) { - // Side buttons fire on press only when long-press action is OFF (nothing to detect). - const bool sideUsePress = SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_OFF; - const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward(); const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack(); - const bool sidePrev = sideUsePress ? input.wasPressed(MappedInputManager::Button::PageBack) - : input.wasReleased(MappedInputManager::Button::PageBack); - const bool sideNext = sideUsePress ? input.wasPressed(MappedInputManager::Button::PageForward) - : input.wasReleased(MappedInputManager::Button::PageForward); const bool frontPrev = input.wasReleased(MappedInputManager::Button::Left); const bool powerReleased = input.wasReleased(MappedInputManager::Button::Power); @@ -215,9 +222,8 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) { const bool powerTurn = shortPowerTurn || longPowerTurn; const bool frontNext = input.wasReleased(MappedInputManager::Button::Right) || powerTurn; - // fromSideBtn is true when only side buttons contributed to this page turn. - const bool fromSide = (sidePrev || sideNext) && !(frontPrev || frontNext); - return {tiltPrev || sidePrev || frontPrev, tiltNext || sideNext || frontNext, fromSide, tiltPrev || tiltNext}; + // Side-button actions are resolved by SideButtonShortcuts in each reader. + return {tiltPrev || frontPrev, tiltNext || frontNext, false, tiltPrev || tiltNext}; } // One helper, blocking or deferred: the async form starts the refresh and diff --git a/src/activities/reader/SideButtonShortcuts.h b/src/activities/reader/SideButtonShortcuts.h new file mode 100644 index 00000000000..14e9c8b4320 --- /dev/null +++ b/src/activities/reader/SideButtonShortcuts.h @@ -0,0 +1,83 @@ +#pragma once + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "ReaderUtils.h" +#include "util/QuickLockTrigger.h" + +// A reader owns one tracker for each logical side button. Short actions fire +// on release; long actions fire once when the hold threshold is reached. +// Tracking only starts on a press edge, so a button already held while a +// reader activity opens cannot turn into an accidental shortcut. +class SideButtonShortcuts { + public: + struct Result { + bool consumed = false; + bool triggered = false; + bool longPress = false; + bool up = false; + uint8_t action = CrossPointSettings::IGNORE; + }; + + static QuickLockTrigger quickLockTrigger(const Result& result) { + if (result.up) return result.longPress ? QuickLockTrigger::SideUpLong : QuickLockTrigger::SideUpShort; + return result.longPress ? QuickLockTrigger::SideDownLong : QuickLockTrigger::SideDownShort; + } + + static bool shouldConsume(const Result& result, const MappedInputManager& input) { + // Chord Page Turn arrives as an injected front-button release while a side + // button may still be held. Let that release reach the reader page handler. + return result.consumed && !input.hasInjectedRelease(MappedInputManager::Button::Right); + } + + Result update(const MappedInputManager& input, const unsigned long now) { + Result upResult = updateOne(input, MappedInputManager::Button::Up, true, up_, now); + Result downResult = updateOne(input, MappedInputManager::Button::Down, false, down_, now); + if (upResult.triggered) return upResult; + if (downResult.triggered) return downResult; + upResult.consumed = upResult.consumed || downResult.consumed; + return upResult; + } + + private: + struct ButtonState { + unsigned long pressedAt = 0; + bool tracking = false; + bool longFired = false; + }; + ButtonState up_; + ButtonState down_; + + static Result updateOne(const MappedInputManager& input, const MappedInputManager::Button button, const bool up, + ButtonState& state, const unsigned long now) { + Result result; + result.up = up; + if (input.wasPressed(button)) { + state.pressedAt = now; + state.tracking = true; + state.longFired = false; + } + if (!state.tracking) return result; + + result.consumed = true; + const bool pressed = input.isPressed(button); + const bool released = input.wasReleased(button); + const uint8_t longAction = up ? SETTINGS.sideButtonUpLong : SETTINGS.sideButtonDownLong; + if (!state.longFired && longAction != CrossPointSettings::IGNORE && + now - state.pressedAt >= ReaderUtils::SKIP_HOLD_MS && (pressed || released)) { + state.longFired = true; + result.triggered = true; + result.longPress = true; + result.action = longAction; + } else if (released) { + if (!state.longFired) { + result.triggered = true; + result.action = up ? SETTINGS.sideButtonUpShort : SETTINGS.sideButtonDownShort; + } + } + if (!pressed) state.tracking = false; + return result; + } +}; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 8abb4817e97..ac86a6c1a5f 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -153,6 +153,9 @@ void TxtReaderActivity::onEnter() { void TxtReaderActivity::onExit() { mappedInput.setReaderTouchscreenOverride(false); Activity::onExit(); + if (auto* fontCache = renderer.getFontCacheManager()) { + fontCache->releaseSdFontCaches(); + } // Deactivate reader-specific front button mapping. mappedInput.setReaderMode(false); @@ -276,42 +279,43 @@ void TxtReaderActivity::loop() { return; } - const bool sideLongPressChangesFont = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_FONT_SIZE; - const bool sideLongPressChangesOrientation = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_ORIENTATION_CHANGE; - if (sideLongPressChangesFont || sideLongPressChangesOrientation) { - const bool topReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); - const bool bottomReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); - if (sideButtonLongPressHandled && (topReleased || bottomReleased)) { - sideButtonLongPressHandled = false; - return; - } - - const bool longPressReady = mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS; - const bool topLongPressed = - longPressReady && (mappedInput.isPressed(MappedInputManager::Button::Up) || topReleased); - const bool bottomLongPressed = - longPressReady && (mappedInput.isPressed(MappedInputManager::Button::Down) || bottomReleased); - - if (!sideButtonLongPressHandled && (topLongPressed || bottomLongPressed)) { - sideButtonLongPressHandled = !(topReleased || bottomReleased); - if (sideLongPressChangesFont) { - changeReaderFontSize(/*larger=*/topLongPressed); - return; - } - SETTINGS.orientation = ReaderUtils::rotatedOrientation(SETTINGS.orientation, /*clockwise=*/bottomLongPressed); - SETTINGS.saveToFile(); - { - RenderLock lock(*this); - ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); - pageOffsets.clear(); - currentPageLines.clear(); - initialized = false; + const auto side = sideButtonShortcuts.update(mappedInput, millis()); + if (SideButtonShortcuts::shouldConsume(side, mappedInput)) { + if (side.triggered) { + if (side.longPress) + mappedInput.suppressNextSideRelease(side.up ? MappedInputManager::Button::Up + : MappedInputManager::Button::Down); + switch (side.action) { + case CrossPointSettings::PAGE_TURN: + if (currentPage < totalPages - 1) { + currentPage++; + requestUpdate(); + } + break; + case CrossPointSettings::SIDE_INCREASE_FONT: + case CrossPointSettings::SIDE_DECREASE_FONT: + changeReaderFontSize(side.action == CrossPointSettings::SIDE_INCREASE_FONT); + break; + case CrossPointSettings::SIDE_ROTATE_COUNTERCLOCKWISE: + case CrossPointSettings::SIDE_ROTATE_CLOCKWISE: + handleTwoFingerRotation(side.action == CrossPointSettings::SIDE_ROTATE_CLOCKWISE); + break; + case CrossPointSettings::SIDE_PREVIOUS_CHAPTER: + case CrossPointSettings::SIDE_NEXT_CHAPTER: + case CrossPointSettings::IGNORE: + // Plain text has no chapter model. + break; + default: { + const auto action = static_cast(side.action); + if (action == CrossPointSettings::QUICK_LOCK) + handleGlobalPowerButtonAction(action, SideButtonShortcuts::quickLockTrigger(side)); + else if (!handleShortcutAction(action)) + handleGlobalPowerButtonAction(action); + break; + } } - requestUpdate(); - return; } + return; } const bool frontLongPressChangesFont = SETTINGS.longPressButtonBehavior == CrossPointSettings::FONT_SIZE_CHANGE; diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index dac988131ad..34e2296435e 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -6,6 +6,7 @@ #include "CrossPointSettings.h" #include "ReaderProgressSaveDebouncer.h" +#include "SideButtonShortcuts.h" #include "activities/Activity.h" #include "components/OptionPopup.h" #if CROSSINK_APP_CAP_TOUCH @@ -21,7 +22,7 @@ class TxtReaderActivity final : public Activity { int pagesUntilFullRefresh = 0; // Session-only display toggle; cached page layout remains unchanged. bool statusBarVisible = true; - bool sideButtonLongPressHandled = false; + SideButtonShortcuts sideButtonShortcuts; bool frontButtonLongPressHandled = false; bool longPowerButtonHandled = false; bool longPressBackHandled = false; diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 607e0a9c101..6ac2c8c9893 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -16,6 +16,7 @@ #include #include "BookStatsActivity.h" +#include "BookStatsTracking.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "GlobalActions.h" @@ -127,6 +128,10 @@ void XtcReaderActivity::onEnter() { stats = BookReadingStats::load(xtc->getCachePath()); globalStats = GlobalReadingStats::load(); + bookStatsEnabled = BookStatsTracking::isBookEnabled(xtc->getCachePath()); + statsTrackingActive = SETTINGS.shouldTrackReadingStats() && bookStatsEnabled; + paceDirty = false; + pendingStatsCommit = false; sessionReadingSeconds = 0; hasSessionStartLocalDateTime = getCurrentLocalReadingStatsDateTime(sessionStartLocalDateTime); @@ -157,6 +162,7 @@ void XtcReaderActivity::onExit() { APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); + syncStatsTrackingState(); commitReadingStats(); // Generate carousel thumbnails while XTC is still loaded so the home screen @@ -184,7 +190,8 @@ void XtcReaderActivity::openReaderMenu() { pauseReadingStatsTimer("reader_menu"); startActivityForResult( - std::make_unique(renderer, mappedInput, std::move(title), hasChapters, stats.isCompleted), + std::make_unique(renderer, mappedInput, std::move(title), hasChapters, stats.isCompleted, + SETTINGS.shouldTrackReadingStats(), bookStatsEnabled), [this](const ActivityResult& result) { const auto* menu = std::get_if(&result.data); if (result.isCancelled || menu == nullptr) { @@ -200,12 +207,17 @@ void XtcReaderActivity::loop() { if (!xtc) { return; } + syncStatsTrackingState(); if (quickActionsPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; - const bool shortcutPageTurn = shortcutPageTurnPending; + bool shortcutPageTurn = shortcutPageTurnPending; + bool shortcutPageTurnFromSide = shortcutPageTurnPendingFromSide; shortcutPageTurnPending = false; - const bool shortcutPreviousPage = shortcutPreviousPagePending; + shortcutPageTurnPendingFromSide = false; + bool shortcutPreviousPage = shortcutPreviousPagePending; + bool shortcutPreviousPageFromSide = shortcutPreviousPagePendingFromSide; shortcutPreviousPagePending = false; + shortcutPreviousPagePendingFromSide = false; const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); const int bottomHeight = UITheme::getReaderStatusBarHeight(ReaderStatusBarPosition::Bottom); @@ -361,75 +373,73 @@ void XtcReaderActivity::loop() { return; } - const bool sideLongPressSkipsChapter = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_CHAPTER_SKIP; - if (sideLongPressSkipsChapter) { - const bool sidePrevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack); - const bool sideNextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward); - if (sideButtonLongPressHandled && (sidePrevReleased || sideNextReleased)) { - sideButtonLongPressHandled = false; - return; - } - - const bool longPressReady = mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS; - const bool prevLongPressed = longPressReady && mappedInput.isPressed(MappedInputManager::Button::PageBack); - const bool nextLongPressed = longPressReady && mappedInput.isPressed(MappedInputManager::Button::PageForward); - if (!sideButtonLongPressHandled && (prevLongPressed || nextLongPressed)) { - sideButtonLongPressHandled = true; - - bool goHome = false; - bool needsUpdate = false; - { - RenderLock lock(*this); - const uint32_t pageCount = xtc->getPageCount(); - if (currentPage >= pageCount) { - if (nextLongPressed) { - goHome = true; - } else { - currentPage = pageCount > 0 ? pageCount - 1 : 0; - needsUpdate = true; - } - } else if (prevLongPressed && currentPage == 0) { - // First page of the book: nothing to skip back to. - } else { - uint32_t forwardReadSeconds = 0; - const bool shouldRecordForwardRead = - nextLongPressed && forwardPageReadElapsed(forwardReadSeconds, "side_long_press"); - recordCurrentPageReadingTime("side_long_press"); - if (prevLongPressed) { - currentPage = currentPage >= 10 ? currentPage - 10 : 0; - } else { - currentPage += 10; + const bool chordPageTurn = mappedInput.hasInjectedRelease(MappedInputManager::Button::Right); + const auto side = sideButtonShortcuts.update(mappedInput, millis()); + if (SideButtonShortcuts::shouldConsume(side, mappedInput)) { + if (side.triggered) { + if (side.longPress) + mappedInput.suppressNextSideRelease(side.up ? MappedInputManager::Button::Up + : MappedInputManager::Button::Down); + switch (side.action) { + case CrossPointSettings::PAGE_TURN: + shortcutPageTurn = true; + shortcutPageTurnFromSide = true; + break; + case CrossPointSettings::PREVIOUS_PAGE: + shortcutPreviousPage = true; + shortcutPreviousPageFromSide = true; + break; + case CrossPointSettings::SIDE_PREVIOUS_CHAPTER: + case CrossPointSettings::SIDE_NEXT_CHAPTER: { + // Fixed-page books keep the established ten-page chapter-skip step. + const bool next = side.action == CrossPointSettings::SIDE_NEXT_CHAPTER; + if (!next && currentPage == 0) break; // First page of the book: nothing to skip back to. + bool goHome = false; + { + RenderLock lock(*this); + const uint32_t pageCount = xtc->getPageCount(); if (currentPage >= pageCount) { - currentPage = pageCount; - } - if (shouldRecordForwardRead) { - recordForwardPageTurn(forwardReadSeconds, false); + if (next) + goHome = true; + else + currentPage = pageCount > 0 ? pageCount - 1 : 0; + } else { + uint32_t forwardReadSeconds = 0; + const bool recordForward = next && forwardPageReadElapsed(forwardReadSeconds, "side_long_press"); + recordCurrentPageReadingTime("side_long_press"); + currentPage = + next ? std::min(currentPage + 10, pageCount) : (currentPage >= 10 ? currentPage - 10 : 0); + if (recordForward) recordForwardPageTurn(forwardReadSeconds, false); } } - needsUpdate = true; + if (goHome) + onGoHome(); + else + requestUpdate(); + break; + } + case CrossPointSettings::SIDE_INCREASE_FONT: + case CrossPointSettings::SIDE_DECREASE_FONT: + case CrossPointSettings::SIDE_ROTATE_COUNTERCLOCKWISE: + case CrossPointSettings::SIDE_ROTATE_CLOCKWISE: + case CrossPointSettings::IGNORE: + // XTC pages are pre-rendered; font size and rotation do not apply. + break; + default: { + const auto action = static_cast(side.action); + if (action == CrossPointSettings::QUICK_LOCK) + handleGlobalPowerButtonAction(action, SideButtonShortcuts::quickLockTrigger(side)); + else if (!handleShortcutAction(action)) + handleGlobalPowerButtonAction(action); + break; } } - if (goHome) { - onGoHome(); - return; - } - if (needsUpdate) { - requestUpdate(); - } - return; } + if (!shortcutPageTurn && !shortcutPreviousPage) return; } - // Side buttons fire on press only when long-press action is OFF. - const bool sideUsePress = SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_OFF; - const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward(); const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack(); - const bool sidePrev = sideUsePress ? mappedInput.wasPressed(MappedInputManager::Button::PageBack) - : mappedInput.wasReleased(MappedInputManager::Button::PageBack); - const bool sideNext = sideUsePress ? mappedInput.wasPressed(MappedInputManager::Button::PageForward) - : mappedInput.wasReleased(MappedInputManager::Button::PageForward); const bool frontPrev = mappedInput.wasReleased(MappedInputManager::Button::Left); const bool powerReleased = mappedInput.wasReleased(MappedInputManager::Button::Power); if (longPowerPageTurnHandled && (powerReleased || !mappedInput.isPressed(MappedInputManager::Button::Power))) { @@ -527,10 +537,9 @@ void XtcReaderActivity::loop() { } } - const bool fromSideBtn = (sidePrev || sideNext) && !(frontPrev || frontNext); const bool fromTilt = tiltPrev || tiltNext; - bool prevTriggered = tiltPrev || sidePrev || frontPrev; - bool nextTriggered = tiltNext || sideNext || frontNext; + bool prevTriggered = tiltPrev || frontPrev; + bool nextTriggered = tiltNext || frontNext; prevTriggered = prevTriggered || touch.prev || shortcutPreviousPage; nextTriggered = nextTriggered || touch.next || shortcutPageTurn; @@ -544,28 +553,24 @@ void XtcReaderActivity::loop() { const bool fromTouch = touch.prev || touch.next; const unsigned long heldMs = fromTouch ? touch.heldMs : mappedInput.getHeldTime(); - // XTC pages are fixed-size bitmaps, so the orientation long-press action is - // consumed here instead of rotating/clipping the pre-rendered page image. - if (fromSideBtn && - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_ORIENTATION_CHANGE && - heldMs > ReaderUtils::SKIP_HOLD_MS) { - return; - } - // Mirror EPUB's manual-turn guard: the render task updates the panel // concurrently, so accepting another turn before it owns RenderLock can mix // two pages. The short time gap covers that request-to-render startup window. constexpr unsigned long kMinManualTurnGapMs = 200; const unsigned long now = millis(); if (RenderLock::peek() || (now - lastPageTurnTime) < kMinManualTurnGapMs) { + shortcutPageTurnPending = shortcutPageTurnPending || shortcutPageTurn; + shortcutPageTurnPendingFromSide = shortcutPageTurnPendingFromSide || shortcutPageTurnFromSide; + shortcutPreviousPagePending = shortcutPreviousPagePending || shortcutPreviousPage; + shortcutPreviousPagePendingFromSide = shortcutPreviousPagePendingFromSide || shortcutPreviousPageFromSide; return; } lastPageTurnTime = now; - const bool skipPages = - !fromTouch && !fromTilt && !powerPageTurn && heldMs > ReaderUtils::SKIP_HOLD_MS && - (fromSideBtn ? SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_CHAPTER_SKIP - : SETTINGS.longPressButtonBehavior == CrossPointSettings::CHAPTER_SKIP); + const bool sideShortcutPageTurn = shortcutPageTurnFromSide || shortcutPreviousPageFromSide; + const bool skipPages = !chordPageTurn && !sideShortcutPageTurn && !fromTouch && !fromTilt && !powerPageTurn && + heldMs > ReaderUtils::SKIP_HOLD_MS && + SETTINGS.longPressButtonBehavior == CrossPointSettings::CHAPTER_SKIP; const int skipAmount = skipPages ? 10 : 1; bool goHome = false; @@ -642,6 +647,23 @@ void XtcReaderActivity::pauseReadingStatsTimer(const char* source) { pageShownAtMs = 0UL; } +void XtcReaderActivity::syncStatsTrackingState() { + if (!xtc) return; + const bool active = SETTINGS.shouldTrackReadingStats() && bookStatsEnabled; + if (active == statsTrackingActive) return; + if (statsTrackingActive) { // active differs, so tracking is turning off + pendingStatsCommit = true; + if (stats.save(xtc->getCachePath())) { + globalStats.save(); + pendingStatsCommit = false; + } + } + statsTrackingActive = active; + sessionReadingSeconds = 0; + hasSessionStartLocalDateTime = getCurrentLocalReadingStatsDateTime(sessionStartLocalDateTime); + pageShownAtMs = millis(); +} + void XtcReaderActivity::resumeReadingStatsTimer(const char*) { if (xtc && currentPage < xtc->getPageCount()) { pageShownAtMs = millis(); @@ -701,7 +723,7 @@ bool XtcReaderActivity::handleQuickLockUnlock(const QuickLockTrigger trigger) { bool XtcReaderActivity::currentPageReadingSecondsForStats(uint32_t& seconds, const char* source) const { seconds = 0; - if (!SETTINGS.shouldTrackReadingStats() || pageShownAtMs == 0UL) { + if (!statsTrackingActive || pageShownAtMs == 0UL) { return false; } @@ -722,7 +744,7 @@ bool XtcReaderActivity::currentPageReadingSecondsForStats(uint32_t& seconds, con bool XtcReaderActivity::forwardPageReadElapsed(uint32_t& seconds, const char*) const { seconds = 0; - if (!SETTINGS.shouldTrackReadingStats() || pageShownAtMs == 0UL) { + if (pageShownAtMs == 0UL) { return false; } @@ -751,9 +773,12 @@ void XtcReaderActivity::recordCurrentPageReadingTime(const char* source) { void XtcReaderActivity::recordForwardPageTurn(const uint32_t seconds, const bool recordPace) { if (recordPace) { stats.recordForwardPageRead(seconds); + paceDirty = true; + } + if (statsTrackingActive) { + stats.totalPagesTurned++; + globalStats.totalPagesTurned++; } - stats.totalPagesTurned++; - globalStats.totalPagesTurned++; } bool XtcReaderActivity::formatTimeLeftLabel(char* buf, const size_t len, const uint32_t pageToRender, @@ -780,17 +805,16 @@ bool XtcReaderActivity::formatTimeLeftLabel(char* buf, const size_t len, const u } void XtcReaderActivity::commitReadingStats() { - if (!xtc || !SETTINGS.shouldTrackReadingStats()) { + if (!xtc) { return; } - - recordCurrentPageReadingTime("reader_exit"); + if (statsTrackingActive) recordCurrentPageReadingTime("reader_exit"); const uint32_t elapsedSecs = sessionReadingSeconds; - if (elapsedSecs >= 60) { + if (statsTrackingActive && elapsedSecs >= 60) { stats.sessionCount++; globalStats.totalSessions++; } - if (elapsedSecs >= 10) { + if (statsTrackingActive && elapsedSecs >= 10) { stats.totalReadingSeconds += elapsedSecs; globalStats.totalReadingSeconds += elapsedSecs; if (hasSessionStartLocalDateTime) { @@ -801,8 +825,9 @@ void XtcReaderActivity::commitReadingStats() { stats.startDate = sessionStartLocalDateTime.date; } } - stats.save(xtc->getCachePath()); - globalStats.save(); + if (statsTrackingActive || paceDirty || pendingStatsCommit) { + if (stats.save(xtc->getCachePath()) && (statsTrackingActive || pendingStatsCommit)) globalStats.save(); + } } void XtcReaderActivity::resetCurrentBookStatsAfterDelete() { @@ -817,21 +842,21 @@ void XtcReaderActivity::setBookCompleted(const bool isCompleted) { } stats.isCompleted = isCompleted; - if (isCompleted && !stats.finishedDateManual) { + if (isCompleted && statsTrackingActive && !stats.finishedDateManual) { ReadingStatsDateTime now; if (getCurrentLocalReadingStatsDateTime(now)) { stats.finishedDate = now.date; } } - if (isCompleted) { - globalStats.completedBooks++; - } else if (globalStats.completedBooks > 0) { - globalStats.completedBooks--; + if (stats.save(xtc->getCachePath()) && statsTrackingActive) { + if (isCompleted) { + globalStats.completedBooks++; + } else if (globalStats.completedBooks > 0) { + globalStats.completedBooks--; + } + globalStats.save(); } - - stats.save(xtc->getCachePath()); - globalStats.save(); } float XtcReaderActivity::getCurrentBookProgressPercent() const { @@ -859,19 +884,17 @@ bool XtcReaderActivity::getFrontlightPanelBookDetails(FrontlightPanelBookDetails } std::unique_ptr XtcReaderActivity::createFrontlightReadingStatsActivity() { - if (!xtc) return {}; + if (!xtc || !statsTrackingActive) return {}; BookReadingStats displayStats = stats; - if (SETTINGS.shouldTrackReadingStats()) { - displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - sessionReadingSeconds + displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - sessionReadingSeconds + ? UINT32_MAX + : displayStats.totalReadingSeconds + sessionReadingSeconds; + uint32_t currentPageSeconds = 0; + if (currentPageReadingSecondsForStats(currentPageSeconds, "frontlight_stats_preview")) { + displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - currentPageSeconds ? UINT32_MAX - : displayStats.totalReadingSeconds + sessionReadingSeconds; - uint32_t currentPageSeconds = 0; - if (currentPageReadingSecondsForStats(currentPageSeconds, "frontlight_stats_preview")) { - displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - currentPageSeconds - ? UINT32_MAX - : displayStats.totalReadingSeconds + currentPageSeconds; - } + : displayStats.totalReadingSeconds + currentPageSeconds; } const bool hasSyncedStats = GlobalReadingStats::hasSyncedStats(); @@ -919,6 +942,7 @@ void XtcReaderActivity::openChapterSelection() { } void XtcReaderActivity::openReadingStats() { + if (!statsTrackingActive) return; auto bookStats = createFrontlightReadingStatsActivity(); if (!bookStats) { resumeReadingStatsTimer("book_stats_no_book"); @@ -993,13 +1017,26 @@ void XtcReaderActivity::onReaderMenuConfirm(const int action) { case XtcReaderMenuActivity::MenuAction::READING_STATS: openReadingStats(); break; + case XtcReaderMenuActivity::MenuAction::TOGGLE_BOOK_STATS_TRACKING: + if (xtc && SETTINGS.shouldTrackReadingStats()) { + const bool enabled = !bookStatsEnabled; + if (!BookStatsTracking::setBookEnabled(xtc->getCachePath(), enabled)) { + drawToast(renderer, (std::string(tr(STR_TRACK_READING_STATS)) + " " + tr(STR_FAILED_LOWER)).c_str()); + delay(1000); + } + bookStatsEnabled = BookStatsTracking::isBookEnabled(xtc->getCachePath()); + syncStatsTrackingState(); + } + resumeReadingStatsTimer("toggle_book_stats_return"); + requestUpdate(); + break; case XtcReaderMenuActivity::MenuAction::TOGGLE_COMPLETED: setBookCompleted(!stats.isCompleted); resumeReadingStatsTimer("toggle_completed_return"); requestUpdate(); break; case XtcReaderMenuActivity::MenuAction::DELETE_STATS: - deleteBookStats(); + if (statsTrackingActive) deleteBookStats(); break; case XtcReaderMenuActivity::MenuAction::DELETE_CACHE: deleteBookCache(); diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index e3034d3eeb7..b5a9dc1c084 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -17,6 +17,7 @@ #include "EndOfBookOptions.h" #include "GlobalReadingStats.h" #include "ReaderProgressSaveDebouncer.h" +#include "SideButtonShortcuts.h" #include "activities/Activity.h" #include "components/OptionPopup.h" @@ -33,15 +34,21 @@ class XtcReaderActivity final : public Activity { GlobalReadingStats globalStats; ReadingStatsDateTime sessionStartLocalDateTime; bool hasSessionStartLocalDateTime = false; + bool bookStatsEnabled = true; + bool statsTrackingActive = true; + bool paceDirty = false; + bool pendingStatsCommit = false; bool longPowerPageTurnHandled = false; // Home-key shortcuts are dispatched before this activity's normal input loop. // Queue the turn so it follows the same guarded XTC page-turn path. bool shortcutPageTurnPending = false; + bool shortcutPageTurnPendingFromSide = false; bool shortcutPreviousPagePending = false; + bool shortcutPreviousPagePendingFromSide = false; // Session-only display toggle; fixed-layout XTC pages are never regenerated. bool statusBarVisible = true; bool longPressMenuHandled = false; - bool sideButtonLongPressHandled = false; + SideButtonShortcuts sideButtonShortcuts; bool frontButtonLongPressHandled = false; bool longPressBackHandled = false; bool skipRecentBookUpdateOnEntry = false; @@ -66,6 +73,7 @@ class XtcReaderActivity final : public Activity { bool flushQueuedProgress(); void loadProgress(); void pauseReadingStatsTimer(const char* source = "unknown"); + void syncStatsTrackingState(); void resumeReadingStatsTimer(const char* source = "unknown"); bool currentPageReadingSecondsForStats(uint32_t& seconds, const char* source) const; bool forwardPageReadElapsed(uint32_t& seconds, const char* source) const; diff --git a/src/activities/reader/XtcReaderMenuActivity.cpp b/src/activities/reader/XtcReaderMenuActivity.cpp index 4b33183df18..9e7c36eb562 100644 --- a/src/activities/reader/XtcReaderMenuActivity.cpp +++ b/src/activities/reader/XtcReaderMenuActivity.cpp @@ -26,24 +26,33 @@ constexpr fui::ActionId kActionRow = 1; } // namespace XtcReaderMenuActivity::XtcReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title, - const bool hasChapters, const bool isBookCompleted) + const bool hasChapters, const bool isBookCompleted, + const bool globalStatsEnabled, const bool bookStatsEnabled) : Activity("XtcReaderMenu", renderer, mappedInput), title(std::move(title)), - items(buildMenuItems(hasChapters, isBookCompleted, mappedInput.hasTouchHardware())), + items(buildMenuItems(hasChapters, isBookCompleted, mappedInput.hasTouchHardware(), globalStatsEnabled, + bookStatsEnabled)), + bookStatsEnabled(bookStatsEnabled), ui(renderer) {} std::vector XtcReaderMenuActivity::buildMenuItems(const bool hasChapters, const bool isBookCompleted, - const bool hasTouch) { + const bool hasTouch, + const bool globalStatsEnabled, + const bool bookStatsEnabled) { std::vector menuItems; - menuItems.reserve(6 + (hasTouch ? 1u : 0u)); + menuItems.reserve(7 + (hasTouch ? 1u : 0u)); if (hasChapters) { menuItems.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); } - menuItems.push_back({MenuAction::READING_STATS, StrId::STR_READING_STATS}); + if (globalStatsEnabled) { + menuItems.push_back({MenuAction::TOGGLE_BOOK_STATS_TRACKING, StrId::STR_TRACK_READING_STATS}); + if (bookStatsEnabled) menuItems.push_back({MenuAction::READING_STATS, StrId::STR_READING_STATS}); + } menuItems.push_back( {MenuAction::TOGGLE_COMPLETED, isBookCompleted ? StrId::STR_MARK_UNFINISHED : StrId::STR_MARK_FINISHED}); - menuItems.push_back({MenuAction::DELETE_STATS, StrId::STR_DELETE_BOOK_STATS}); + if (globalStatsEnabled && bookStatsEnabled) + menuItems.push_back({MenuAction::DELETE_STATS, StrId::STR_DELETE_BOOK_STATS}); menuItems.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}); menuItems.push_back({MenuAction::SEND_NEARBY_BOOK, StrId::STR_SEND_NEARBY_BOOK}); if (hasTouch) { @@ -179,6 +188,8 @@ void XtcReaderMenuActivity::refreshListItems() { listItems[index].actionValue = static_cast(index); if (items[index].action == MenuAction::DISABLE_TOUCHSCREEN) { listItems[index].value = I18N.get(SETTINGS.disableReaderTouchscreen ? StrId::STR_ON : StrId::STR_OFF); + } else if (items[index].action == MenuAction::TOGGLE_BOOK_STATS_TRACKING) { + listItems[index].value = I18N.get(bookStatsEnabled ? StrId::STR_ON : StrId::STR_OFF); } } } diff --git a/src/activities/reader/XtcReaderMenuActivity.h b/src/activities/reader/XtcReaderMenuActivity.h index bffea92aa42..17ef060c351 100644 --- a/src/activities/reader/XtcReaderMenuActivity.h +++ b/src/activities/reader/XtcReaderMenuActivity.h @@ -16,6 +16,7 @@ class XtcReaderMenuActivity final : public Activity { enum class MenuAction { SELECT_CHAPTER, READING_STATS, + TOGGLE_BOOK_STATS_TRACKING, TOGGLE_COMPLETED, DELETE_STATS, DELETE_CACHE, @@ -24,7 +25,7 @@ class XtcReaderMenuActivity final : public Activity { }; XtcReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title, bool hasChapters, - bool isBookCompleted); + bool isBookCompleted, bool globalStatsEnabled, bool bookStatsEnabled); void onEnter() override; void onExit() override; @@ -35,21 +36,23 @@ class XtcReaderMenuActivity final : public Activity { bool allowGlobalHomeGesture() const override { return false; } private: - using UiHost = UiAppHost<8, 2>; + using UiHost = UiAppHost<9, 2>; using UiApp = UiHost::App; - static constexpr size_t kMaxMenuItems = 7; + static constexpr size_t kMaxMenuItems = 8; struct MenuItem { MenuAction action; StrId labelId; }; - static std::vector buildMenuItems(bool hasChapters, bool isBookCompleted, bool hasTouch); + static std::vector buildMenuItems(bool hasChapters, bool isBookCompleted, bool hasTouch, + bool globalStatsEnabled, bool bookStatsEnabled); void finishCancelled(); ButtonNavigator buttonNavigator; std::string title; std::vector items; + bool bookStatsEnabled = true; std::array listItems{}; int selectedIndex = 0; UiHost ui; diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index 5a1d5c241e7..e0c8642e960 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -190,7 +190,10 @@ void OtaUpdateActivity::render(RenderLock&&) { GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } else if (state == FINISHED) { renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_UPDATE_COMPLETE), true, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_10_FONT_ID, top + height + metrics.verticalSpacing, tr(STR_POWER_ON_HINT)); + const int hintY = top + height + metrics.verticalSpacing; + const Rect hintBounds{metrics.contentSidePadding, hintY, pageWidth - metrics.contentSidePadding * 2, + pageHeight - hintY}; + UITheme::drawCenteredWrappedText(renderer, hintBounds, UI_10_FONT_ID, hintY, tr(STR_POWER_ON_HINT), 3); } renderer.displayBuffer(screenTransitionRefresh.modeFor(static_cast(state))); diff --git a/src/activities/settings/QuickActionsActivity.cpp b/src/activities/settings/QuickActionsActivity.cpp index 824d36aa433..11dfd1b323e 100644 --- a/src/activities/settings/QuickActionsActivity.cpp +++ b/src/activities/settings/QuickActionsActivity.cpp @@ -18,6 +18,15 @@ constexpr StrId triggerLabels[] = { StrId::STR_TAP_HOME_SHORTCUT, StrId::STR_LONG_PRESS_HOME_SHORTCUT, StrId::STR_DOUBLE_TAP_HOME_SHORTCUT, StrId::STR_SIDE_BUTTON_CHORD}; +std::string triggerLabel(const QuickActions::Trigger trigger) { + const auto raw = static_cast(trigger); + if (raw < std::size(triggerLabels)) return I18N.get(triggerLabels[raw]); + const bool up = trigger == QuickActions::Trigger::SideUpShort || trigger == QuickActions::Trigger::SideUpLong; + const bool longPress = trigger == QuickActions::Trigger::SideUpLong || trigger == QuickActions::Trigger::SideDownLong; + return std::string(up ? tr(STR_DIR_LEFT) : tr(STR_DIR_RIGHT)) + "/" + (up ? tr(STR_DIR_UP) : tr(STR_DIR_DOWN)) + " " + + (longPress ? tr(STR_LONG_PRESS_ACTION) : tr(STR_SHORT_PWR_BTN)); +} + std::vector availableTriggers() { std::vector triggers = {QuickActions::Trigger::None, QuickActions::Trigger::ShortPower, QuickActions::Trigger::LongPower, QuickActions::Trigger::PowerUp}; @@ -26,6 +35,10 @@ std::vector availableTriggers() { triggers.push_back(QuickActions::Trigger::UpDown); } #endif + triggers.push_back(QuickActions::Trigger::SideUpShort); + triggers.push_back(QuickActions::Trigger::SideUpLong); + triggers.push_back(QuickActions::Trigger::SideDownShort); + triggers.push_back(QuickActions::Trigger::SideDownLong); if (gpio.hasHomeKey()) { triggers.push_back(QuickActions::Trigger::TapHome); triggers.push_back(QuickActions::Trigger::LongPressHome); @@ -79,8 +92,7 @@ void QuickActionsActivity::showOverview() { if (std::find(triggers.begin(), triggers.end(), draftTrigger) == triggers.end()) { draftTrigger = QuickActions::Trigger::None; } - rows.emplace_back(std::string(I18N.get(StrId::STR_SHORTCUT)) + ": " + - I18N.get(triggerLabels[static_cast(draftTrigger)])); + rows.emplace_back(std::string(I18N.get(StrId::STR_SHORTCUT)) + ": " + triggerLabel(draftTrigger)); for (uint8_t i = 0; i < 5; ++i) { const uint8_t action = draftSlots[i]; const char* label = @@ -105,7 +117,7 @@ void QuickActionsActivity::editShortcut() { labels.reserve(triggers.size()); uint8_t current = 0; for (uint8_t i = 0; i < triggers.size(); ++i) { - labels.emplace_back(I18N.get(triggerLabels[static_cast(triggers[i])])); + labels.emplace_back(triggerLabel(triggers[i])); if (triggers[i] == draftTrigger) current = i; } popup.show(StrId::STR_SHORTCUT, labels, current, [this, triggers](int selected) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 32c6eceb9de..dd981c864c9 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -73,7 +73,7 @@ constexpr size_t controlsHomeButtonCount = 4; constexpr size_t controlsPowerMinCount = 2; constexpr size_t controlsPowerMaxCount = 3; constexpr size_t controlsFrontButtonCount = 6; -constexpr size_t controlsSideButtonBaseCount = 3; +constexpr size_t controlsSideButtonBaseCount = 7; void formatFrontlightScheduleTime(const uint16_t timeOfDay, char* const buf, const size_t len) { const FrontlightSchedule::TimeOfDay time = FrontlightSchedule::timeOfDayFromMinutes(timeOfDay); @@ -590,7 +590,7 @@ void SettingsActivity::openEnumOptionPicker(const SettingInfo& setting) { std::vector options; options.reserve(optionCount); for (uint8_t i = 0; i < optionCount; i++) { - options.push_back(settingEnumOptionLabel(setting, i)); + options.push_back(sideButtonOptionLabel(setting, i)); } uint8_t currentIndex = 0; @@ -1358,7 +1358,7 @@ std::string SettingsActivity::settingValueText(const SettingInfo& setting) { } if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { const uint8_t displayIndex = enumDisplayIndexForRawValue(setting, SETTINGS.*(setting.valuePtr)); - return settingEnumOptionLabel(setting, displayIndex); + return sideButtonOptionLabel(setting, displayIndex); } if (setting.type == SettingType::ENUM && setting.valueGetter) { return settingEnumOptionLabel(setting, setting.valueGetter()); @@ -1542,6 +1542,8 @@ void SettingsActivity::buildSettingsScreen(UiApp::ScreenType& screen) { // Settings rows. Values are built per render and owned for the draw only. const auto& settings = *currentSettings; + const std::string leftUpLabel = sideButtonGroupLabel(true); + const std::string rightDownLabel = sideButtonGroupLabel(false); std::vector values(settings.size()); std::vector items; items.reserve(settings.size()); @@ -1552,7 +1554,11 @@ void SettingsActivity::buildSettingsScreen(UiApp::ScreenType& screen) { const fui::BitmapRef directionIcon = swipeActionIcon(settings[i].nameId); const fui::BitmapRef endpointIcon = frontlightScheduleEndpointIcon(settings[i]); const fui::BitmapRef itemIcon = directionIcon ? directionIcon : endpointIcon; - item.label = isSectionHeader ? uiListSectionHeaderLabel(values[i], I18N.get(settings[i].nameId)) + const char* sectionLabel = + activeSubmenu == SettingAction::ControlsSideButtons + ? (settings[i].nameId == StrId::STR_DIR_LEFT ? leftUpLabel.c_str() : rightDownLabel.c_str()) + : I18N.get(settings[i].nameId); + item.label = isSectionHeader ? uiListSectionHeaderLabel(values[i], sectionLabel) : (directionIcon ? "" : I18N.get(settings[i].nameId)); item.icon = itemIcon; if (!isSectionHeader && !values[i].empty()) item.value = values[i].c_str(); @@ -1577,6 +1583,10 @@ void SettingsActivity::buildSettingsScreen(UiApp::ScreenType& screen) { // File Browser, reader menus, and the other list-style screens. props.labelText = screen.theme().bodyText; props.labelText.maxLines = 2; + if (activeSubmenu == SettingAction::ControlsSideButtons) { + props.headerText = screen.theme().smallText; + props.headerText.bold = true; + } configureUiListSectionHeaders(props, screen.theme()); const auto rows = configureUiList(props, screen.theme(), screen.body()); visibleRows = rows > 0 ? rows : 1; diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index a390f695fe9..7c0bf62d3fc 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -418,6 +418,7 @@ void BmpViewerActivity::showContextMenu() { return; case FileBrowserAction::DeleteCache: case FileBrowserAction::ReadingStats: + case FileBrowserAction::ToggleBookStatsTracking: case FileBrowserAction::SetSleepFolder: case FileBrowserAction::ClearSleepFolder: case FileBrowserAction::ToggleCompleted: diff --git a/src/activities/util/FrontlightPanelActivity.cpp b/src/activities/util/FrontlightPanelActivity.cpp index e4d55343619..02b96241806 100644 --- a/src/activities/util/FrontlightPanelActivity.cpp +++ b/src/activities/util/FrontlightPanelActivity.cpp @@ -214,6 +214,7 @@ void FrontlightPanelActivity::close() { } void FrontlightPanelActivity::openReadingStats() { + if (!context.showReadingStatsAction || !SETTINGS.shouldTrackReadingStats()) return; if (!context.readingStatsActivity && context.sourceActivity) { context.readingStatsActivity = context.sourceActivity->createFrontlightReadingStatsActivity(); } @@ -270,6 +271,7 @@ void FrontlightPanelActivity::closeSyncDialog() { void FrontlightPanelActivity::activateQuickAction(const int index) { if (index < 0 || index >= 5) return; + if (index == 0 && !context.showReadingStatsAction) return; drawerState.selectedAction = static_cast(index); switch (index) { case 0: @@ -458,14 +460,17 @@ void FrontlightPanelActivity::buildPanelScreen(UiApp::ScreenType& screen) { 0, bottomInset, 0}); const fui::Rect actionBar = screen.takeBottom(ACTION_BAR_HEIGHT); - const int16_t slotWidth = static_cast(actionBar.width / 5); + const bool showStats = context.showReadingStatsAction; + const int16_t slotCount = showStats ? 5 : 4; + const int16_t slotWidth = static_cast(actionBar.width / slotCount); const std::array icons = { fui::bitmapFromIcon(icon_reading_stats_24), fui::bitmapFromIcon(icon_transfer_24), fui::bitmapFromIcon(icon_tabler_moon_filled_24), fui::bitmapFromIcon(icon_sliders_horizontal_24), fui::bitmapFromIcon(pendingTouchscreenDisabled ? icon_device_tablet_off_24 : icon_device_tablet_24)}; - for (int16_t i = 0; i < 5; ++i) { - const int16_t x = static_cast(actionBar.x + i * slotWidth); - const int16_t width = i == 4 ? static_cast(actionBar.right() - x) : slotWidth; + for (int16_t slotIndex = 0; slotIndex < slotCount; ++slotIndex) { + const int16_t i = showStats ? slotIndex : slotIndex + 1; + const int16_t x = static_cast(actionBar.x + slotIndex * slotWidth); + const int16_t width = slotIndex == slotCount - 1 ? static_cast(actionBar.right() - x) : slotWidth; const fui::Rect slot{x, actionBar.y, width, actionBar.height}; screen.frame().hit(slot, ACTION_QUICK, i); screen.target().bitmap(slot, icons[static_cast(i)], fui::BitmapMode::Center); diff --git a/src/activities/util/FrontlightPanelModel.h b/src/activities/util/FrontlightPanelModel.h index 23975b2ff32..cc48f2c6dca 100644 --- a/src/activities/util/FrontlightPanelModel.h +++ b/src/activities/util/FrontlightPanelModel.h @@ -66,6 +66,7 @@ struct FrontlightPanelContext { bool activeReaderBook = false; bool activeEpub = false; bool showReaderDetails = false; + bool showReadingStatsAction = true; std::string bookTitle; std::string bookPath; FrontlightPanelBookDetails bookDetails; diff --git a/src/components/CoverGridHomeUi.cpp b/src/components/CoverGridHomeUi.cpp new file mode 100644 index 00000000000..6d02c346f54 --- /dev/null +++ b/src/components/CoverGridHomeUi.cpp @@ -0,0 +1,265 @@ +#include "CoverGridHomeUi.h" + +#include +#include +#include + +#include +#include +#include + +#include "MappedInputManager.h" +#include "UITheme.h" +#include "components/icons/listIcons.h" + +namespace fui = freeink::ui; +namespace { +constexpr fui::ActionId SELECT = 1; +constexpr int16_t COVER_CELL_INSET = 6; +} // namespace + +CoverGridHomeUi::CoverGridHomeUi(GfxRenderer& renderer) + : UiAppHost(renderer), coverCache(renderer), renderer(renderer) {} + +void CoverGridHomeUi::begin(const std::vector& recent, bool opds, bool continuing, float featuredProgress) { + books = &recent; + hasOpds = opds; + if (!recent.empty()) coverCache.begin(); + reset(); + app.on(SELECT, &CoverGridHomeUi::onAction, this); + app.setScreen(&CoverGridHomeUi::screenFn, this); + refreshCoverPaths(); + progress = continuing && featuredProgress >= 0 ? static_cast(featuredProgress + 0.5f) : -1; + if (progress >= 0) snprintf(progressText, sizeof(progressText), "%d%%", progress); +} + +void CoverGridHomeUi::refreshCoverPaths() { + coverCache.invalidate(); + for (size_t i = 0; i < books->size() && i < coverPaths.size(); ++i) refreshCoverPath(i); +} + +void CoverGridHomeUi::refreshCoverPath(size_t index) { + if (index >= books->size() || index >= coverPaths.size()) return; + coverCache.invalidate(index); + coverPaths[index] = thumbHeights[index] > 0 + ? UITheme::getCoverThumbPath((*books)[index].coverBmpPath, thumbHeights[index]) + : std::string(); +} + +int CoverGridHomeUi::thumbHeightFor(size_t index) const { + return index < thumbHeights.size() && thumbHeights[index] > 0 ? thumbHeights[index] : THUMB_HEIGHT; +} + +bool CoverGridHomeUi::takeThumbHeightsChanged() { return std::exchange(thumbHeightsChanged, false); } + +void CoverGridHomeUi::noteThumbHeight(size_t index, int slotWidth, int slotHeight) { + if (index >= thumbHeights.size()) return; + // Thumbs cover a (0.6*h, h) target box, so a height of max(h, w*5/3) makes + // every cover overfill the slot; the cover renderer crops the overflow (full bleed). + const int height = std::max({1, slotHeight, slotWidth * 5 / 3 + 2}); + if (thumbHeights[index] != height) { + thumbHeights[index] = height; + thumbHeightsChanged = true; + refreshCoverPath(index); + } +} + +void CoverGridHomeUi::onAction(const fui::ActionEvent& event, void* user) { + auto& self = *static_cast(user); + self.pending = event.value; + self.app.clearTapFlash(); +} + +int CoverGridHomeUi::selectedAction(const MappedInputManager& input) { + pending = -1; + fui::ActionEvent event{}; + return routeTouch(input, event) ? pending : -1; +} + +void CoverGridHomeUi::screenFn(UiScreen& screen, void* user) { static_cast(user)->draw(screen); } + +void CoverGridHomeUi::draw(UiScreen& screen) { + coverCache.prepare(); + const auto& theme = screen.theme(); + const auto safe = UITheme::getInstance().getScreenSafeArea(renderer, !BoardConfig::hasTouch()); + 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)}); + const int16_t hInset = std::max( + 0, static_cast(UITheme::getInstance().getMetrics().headerSidePadding - COVER_CELL_INSET - safe.x)); + const int16_t topInset = + BoardConfig::hasTouch() ? static_cast(theme.spaceLg) : static_cast(theme.spaceSm + 4); + screen.insetContent(fui::Insets{topInset, hInset, theme.spaceSm, hInset}); + const bool landscape = renderer.getScreenWidth() > renderer.getScreenHeight(); + screen.takeTop(UITheme::getInstance().getMetrics().batteryBarHeight, theme.spaceSm); + const int16_t tabGap = BoardConfig::hasTouch() ? theme.spaceSm : static_cast(4); + auto tabRect = screen.takeBottom(72, tabGap); + if (books->empty()) { + drawTabs(screen, tabRect.inset(fui::Insets{0, COVER_CELL_INSET, 0, COVER_CELL_INSET})); + drawEmpty(screen); + drawHeaderBand(); + return; + } + const fui::Rect body = screen.body(); + const int rowGap = std::max(4, body.width / 100); + grid.gap = grid.rowGap = rowGap; + const int coverRowHeight = std::max(1, (body.height - theme.spaceSm - rowGap) / 3); + const int16_t featuredHeight = std::min( + body.height, std::max(coverRowHeight, screen.target().lineHeight(theme.bodyText.font) * (landscape ? 1 : 2) + + screen.target().lineHeight(theme.smallText.font) * 2 + 32)); + const auto featuredRect = screen.takeTop(featuredHeight, theme.spaceSm); + const int gridRowHeight = std::max(1, (screen.body().height - rowGap) / GRID_ROWS); + drawCurrent(screen, featuredRect, std::min(coverRowHeight, gridRowHeight)); + drawGrid(screen); + tabRect.x = gridBounds.x + grid.cellInset.left; + tabRect.width = gridBounds.width - grid.cellInset.left - grid.cellInset.right; + drawTabs(screen, tabRect); + drawHeaderBand(); +} + +void CoverGridHomeUi::drawHeaderBand() { + const auto& metrics = UITheme::getInstance().getMetrics(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, renderer.getScreenWidth(), metrics.homeTopPadding}, nullptr); +} + +void CoverGridHomeUi::drawEmpty(UiScreen& screen) { + const auto& theme = screen.theme(); + const auto body = screen.body(); + auto title = theme.titleText; + title.bold = true; + title.align = fui::TextAlign::Center; + auto message = theme.bodyText; + message.align = fui::TextAlign::Center; + constexpr int16_t ICON_SIZE = 32; + const int16_t titleHeight = screen.target().lineHeight(title.font); + const int16_t messageHeight = screen.target().lineHeight(message.font); + const int16_t contentHeight = ICON_SIZE + theme.spaceLg + titleHeight + theme.spaceSm + messageHeight; + int16_t y = body.y + std::max(0, (body.height - contentHeight) / 2); + drawLucideIcon(renderer, icon_book_32, body.x + (body.width - ICON_SIZE) / 2, y); + y += ICON_SIZE + theme.spaceLg; + screen.target().text(fui::Rect{body.x, y, body.width, titleHeight}, tr(STR_NO_OPEN_BOOK), title); + y += titleHeight + theme.spaceSm; + screen.target().text(fui::Rect{body.x, y, body.width, messageHeight}, tr(STR_START_READING), message); +} + +void CoverGridHomeUi::drawCurrent(UiScreen& screen, fui::Rect rect, const int coverRowHeight) { + const auto& theme = screen.theme(); + const auto& book = books->front(); + card.title = book.title.c_str(); + card.author = book.author.empty() ? nullptr : book.author.c_str(); + card.meta = nullptr; + card.progressLabel = progress >= 0 ? progressText : nullptr; + card.centerTextOnCover = true; + card.progress = std::max(0, progress); + card.progressMax = progress >= 0 ? 100 : 0; + card.action = SELECT; + card.state = fui::StateNormal; + card.styles = theme.listRow; + card.styles.selected.background = fui::Paint::dither(fui::Color::LightGray); + card.styles.selected.border = fui::Paint::dither(fui::Color::LightGray); + card.styles.selected.foreground = fui::Paint::solid(fui::Color::Black); + card.styles.selected.radius = theme.listRowRadius; + card.styles.active = card.styles.selected; + card.titleText = theme.bodyText; + card.titleText.maxLines = renderer.getScreenWidth() > renderer.getScreenHeight() ? 1 : 2; + card.authorText = theme.smallText; + card.progressText = theme.smallText; + card.progressHeight = 6; + card.padding = fui::Insets{6, 6, 6, 6}; + card.gap = theme.spaceLg + theme.spaceSm; + const int maxCoverWidth = rect.width / GRID_COLUMNS - 2 * COVER_CELL_INSET; + card.coverSize.height = std::max(1, std::min(std::min(rect.height, coverRowHeight) - 12, maxCoverWidth * 3 / 2)); + card.coverSize.width = std::max(1, card.coverSize.height * 2 / 3); + noteThumbHeight(0, card.coverSize.width, card.coverSize.height); + gridBounds = layoutGrid(screen.body()); + rect.x = gridBounds.x; + rect.width = gridBounds.width; + card.coverPainterUserData = this; + card.coverPainter = [](fui::DrawTarget& target, fui::Rect cover, const fui::BookCardProps&, void* user) { + return static_cast(user)->paintFramedCover(target, cover, 0); + }; + fui::bookCard(screen.frame(), rect, card); + + if (selected == 0 && !BoardConfig::hasTouch()) { + const int16_t barHeight = card.coverSize.height; + screen.target().fill(fui::Rect{static_cast(rect.x - 9), + static_cast(rect.y + (rect.height - barHeight) / 2), 3, barHeight}, + fui::Paint::dither(fui::Color::LightGray)); + } +} + +fui::Rect CoverGridHomeUi::layoutGrid(fui::Rect rect) { + grid.cellInset = fui::Insets{COVER_CELL_INSET, COVER_CELL_INSET, COVER_CELL_INSET, COVER_CELL_INSET}; + grid.coverSize = card.coverSize; + grid.rowHeight = grid.coverSize.height + 12; + rect.height = GRID_ROWS * grid.rowHeight + (GRID_ROWS - 1) * grid.rowGap; + return rect; +} + +void CoverGridHomeUi::drawGrid(UiScreen& screen) { + const auto rect = gridBounds; + grid.count = books->size() > 1 ? books->size() - 1 : 0; + grid.columns = GRID_COLUMNS; + grid.columnLayout = fui::CoverGridColumnLayout::SpaceBetween; + grid.action = SELECT; + grid.inputMask = fui::InputTouch; + grid.selectedIndex = selected > 0 && selected < static_cast(books->size()) ? selected - 1 : -1; + grid.selectionIndicator = fui::CoverGridSelectionIndicator::CoverFrame; + grid.selectedCoverFrameGap = 6; + grid.selectedCoverFrameWidth = 8; + grid.cellStyles = card.styles; + grid.labelHeight = 0; + grid.labelGap = 0; + for (size_t i = 1; i < thumbHeights.size(); ++i) noteThumbHeight(i, grid.coverSize.width, grid.coverSize.height); + grid.scrollIndicator = false; + grid.itemProvider = [](uint16_t index, void*) { return fui::coverGridItem(nullptr, index + 1); }; + grid.coverPainterUserData = this; + grid.coverPainter = [](fui::DrawTarget& target, fui::Rect cover, const fui::CoverGridItem&, uint16_t index, + void* user) { + return static_cast(user)->paintFramedCover(target, cover, index + 1); + }; + fui::coverGrid(screen.frame(), rect, grid); +} + +void CoverGridHomeUi::drawTabs(UiScreen& screen, fui::Rect rect) { + static constexpr const freeink::Icon* ICONS[] = {&icon_folder_32, &icon_landmark_32, &icon_lyra_library_32, + &icon_lyra_transfer_32, &icon_lyra_settings_32}; + int count = 0; + for (int i = 0; i < 5; ++i) { + if (i == 2 && !hasOpds) continue; + auto& tab = tabItems[count]; + tab.value = books->size() + count; + tab.selected = selected == tab.value; + tab.label = nullptr; + ++count; + } + tabs.tabs = tabItems.data(); + tabs.count = count; + tabs.layout = fui::TabBarLayout::SpaceBetween; + tabs.action = SELECT; + tabs.inputMask = fui::InputTouch; + tabs.iconSize = 32; + tabs.iconPainterUserData = this; + tabs.iconPainter = [](fui::DrawTarget&, fui::Rect iconRect, const fui::TabItem& tab, uint8_t, void* user) { + const auto& self = *static_cast(user); + const int index = tab.value - static_cast(self.books->size()); + const int icon = !self.hasOpds && index >= 2 ? index + 1 : index; + drawLucideIcon(self.renderer, *ICONS[icon], iconRect.x, iconRect.y); + return true; + }; + tabs.tabStyles.normal.background = fui::Paint::solid(fui::Color::White); + tabs.tabStyles.selected.background = fui::Paint::solid(fui::Color::White); + tabs.selectedUnderline = 2; + tabs.distributedSlotWidth = 0; + fui::tabBar(screen.frame(), rect, tabs); +} + +bool CoverGridHomeUi::paintFramedCover(fui::DrawTarget& target, fui::Rect rect, size_t index) { + constexpr int16_t SHADOW_OFFSET = 2; + const auto ink = fui::Paint::solid(fui::Color::Black); + target.fill(fui::Rect{rect.right(), static_cast(rect.y + SHADOW_OFFSET), SHADOW_OFFSET, rect.height}, ink); + target.fill(fui::Rect{static_cast(rect.x + SHADOW_OFFSET), rect.bottom(), rect.width, SHADOW_OFFSET}, ink); + const bool drawn = index < coverPaths.size() && coverCache.paint(rect, index, coverPaths[index]); + target.stroke(rect, ink, 1, 0); + return drawn; +} diff --git a/src/components/CoverGridHomeUi.h b/src/components/CoverGridHomeUi.h new file mode 100644 index 00000000000..f246a03e61e --- /dev/null +++ b/src/components/CoverGridHomeUi.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include + +#include "HomeCoverCache.h" +#include "RecentBooksStore.h" +#include "UiAppHost.h" +#include "components/bars/tab-bar.h" +#include "components/media/book-card.h" +#include "components/media/cover-grid.h" + +class CoverGridHomeUi final : public UiAppHost<16, 1> { + public: + using UiScreen = UiAppHost<16, 1>::Screen; + static constexpr int THUMB_HEIGHT = 400; + static constexpr int GRID_COLUMNS = 3; + static constexpr int GRID_ROWS = 2; + static constexpr int MAX_BOOKS = 1 + GRID_COLUMNS * GRID_ROWS; + static_assert(MAX_BOOKS <= HomeCoverCache::MAX_COVERS); + explicit CoverGridHomeUi(GfxRenderer& renderer); + void begin(const std::vector& books, bool hasOpds, bool hasContinueReading, float featuredProgress); + void refreshCoverPaths(); + void setSelection(int selection) { selected = selection; } + int selectedAction(const MappedInputManager& input); + // Exact generation height for a slot, recorded during draw. Thumbs must be + // generated at the drawn size: rescaling a dithered 1-bit image aliases badly. + int thumbHeightFor(size_t index) const; + bool takeThumbHeightsChanged(); + + private: + static void screenFn(UiScreen& screen, void* user); + static void onAction(const freeink::ui::ActionEvent& event, void* user); + void draw(UiScreen& screen); + void drawHeaderBand(); + void drawEmpty(UiScreen& screen); + void drawCurrent(UiScreen& screen, freeink::ui::Rect rect, int coverRowHeight); + void drawGrid(UiScreen& screen); + freeink::ui::Rect layoutGrid(freeink::ui::Rect rect); + void drawTabs(UiScreen& screen, freeink::ui::Rect rect); + bool paintFramedCover(freeink::ui::DrawTarget& target, freeink::ui::Rect rect, size_t index); + void refreshCoverPath(size_t index); + void noteThumbHeight(size_t index, int slotWidth, int slotHeight); + + HomeCoverCache coverCache; + GfxRenderer& renderer; + const std::vector* books = nullptr; + std::array coverPaths; + std::array thumbHeights{}; + bool thumbHeightsChanged = false; + int selected = 0; + int pending = -1; + int progress = -1; + bool hasOpds = false; + char progressText[12]{}; + // Component styles and interaction tables stay off the render task's stack. + freeink::ui::BookCardProps card; + freeink::ui::CoverGridProps grid; + freeink::ui::Rect gridBounds{}; + freeink::ui::TabBarProps tabs; + std::array tabItems; +}; diff --git a/src/components/HomeCoverCache.cpp b/src/components/HomeCoverCache.cpp new file mode 100644 index 00000000000..0852f435b34 --- /dev/null +++ b/src/components/HomeCoverCache.cpp @@ -0,0 +1,88 @@ +#include "HomeCoverCache.h" + +#include +#include + +#include + +#include "UITheme.h" + +namespace fui = freeink::ui; + +void HomeCoverCache::begin() { + // Cover regions do not overlap. Each may widen by one physical byte per row. + coverCacheCapacity = renderer.getRegionByteSize(0, 0, renderer.getScreenWidth(), renderer.getScreenHeight()) + + MAX_COVERS * std::max(renderer.getScreenWidth(), renderer.getScreenHeight()); + coverCache = makePsramByteBufferNoThrow(coverCacheCapacity); + if (!coverCache) { +#if defined(SIMULATOR) + LOG_DBG("HOME", "PSRAM cover cache unavailable in simulator; rendering uncached"); +#else + LOG_ERR("HOME", "PSRAM cover cache unavailable (%u bytes); rendering uncached", unsigned(coverCacheCapacity)); +#endif + coverCacheCapacity = 0; + } +} + +void HomeCoverCache::invalidate() { + coverCacheUsed = 0; + cachedCovers.fill(CachedCover{}); +} + +void HomeCoverCache::invalidate(size_t index) { + if (index < cachedCovers.size()) cachedCovers[index].valid = false; +} + +void HomeCoverCache::prepare() { + const int orientation = static_cast(renderer.getOrientation()); + if (coverCacheOrientation != orientation || coverCacheInverted != bool(SETTINGS.screenInverted)) { + invalidate(); + coverCacheOrientation = orientation; + coverCacheInverted = SETTINGS.screenInverted; + } +} + +bool HomeCoverCache::paint(fui::Rect rect, size_t index, const std::string& path) { + if (index >= cachedCovers.size()) return false; + auto& cached = cachedCovers[index]; + if (coverCache && cached.valid && cached.rect.x == rect.x && cached.rect.y == rect.y && + cached.rect.width == rect.width && cached.rect.height == rect.height && + renderer.copyBufferToRegion(rect.x, rect.y, rect.width, rect.height, coverCache.get() + cached.offset, + cached.bytes)) { + return true; + } + cached.valid = false; + bool drawn = false; + if (!path.empty() && Storage.openFileForRead("HOME", path, coverFile)) { + if (coverBitmap.parseHeaders() == BmpReaderError::Ok && coverBitmap.rewindToData() == BmpReaderError::Ok && + coverBitmap.getWidth() > 0 && coverBitmap.getHeight() > 0) { + const int width = coverBitmap.getWidth(); + const int height = coverBitmap.getHeight(); + const float cropX = width > rect.width ? 1.0f - static_cast(rect.width) / width : 0.0f; + const float cropY = height > rect.height ? 1.0f - static_cast(rect.height) / height : 0.0f; + const int x = rect.x + std::max(0, (rect.width - width) / 2); + const int y = rect.y + std::max(0, (rect.height - height) / 2); + drawn = renderer.drawBitmap(coverBitmap, x, y, rect.width, rect.height, cropX, cropY); + } + coverFile.close(); + } + if (!drawn) { + renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false); + renderer.fillRect(rect.x, rect.y + rect.height / 3, rect.width, rect.height - rect.height / 3, true); + renderer.drawRect(rect.x, rect.y, rect.width, rect.height, true); + } + if (coverCache) { + const size_t needed = renderer.getRegionByteSize(rect.x, rect.y, rect.width, rect.height); + if (needed > cached.bytes && needed <= coverCacheCapacity - coverCacheUsed) { + cached.offset = coverCacheUsed; + cached.bytes = needed; + coverCacheUsed += needed; + } + if (needed > 0 && needed <= cached.bytes) { + cached.rect = rect; + cached.valid = renderer.copyRegionToBuffer(rect.x, rect.y, rect.width, rect.height, + coverCache.get() + cached.offset, cached.bytes); + } + } + return true; // The cover slot is painted, including fallback art. +} diff --git a/src/components/HomeCoverCache.h b/src/components/HomeCoverCache.h new file mode 100644 index 00000000000..57b6599b9e9 --- /dev/null +++ b/src/components/HomeCoverCache.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +class GfxRenderer; + +// Screen-lifetime thumbnail decoding and PSRAM region snapshots. +class HomeCoverCache { + public: + static constexpr size_t MAX_COVERS = 7; + explicit HomeCoverCache(GfxRenderer& renderer) : renderer(renderer) {} + void begin(); + void prepare(); + void invalidate(); + void invalidate(size_t index); + bool paint(freeink::ui::Rect rect, size_t index, const std::string& path); + + private: + struct CachedCover { + freeink::ui::Rect rect{}; + size_t offset = 0; + size_t bytes = 0; + bool valid = false; + }; + HeapByteBuffer coverCache; + std::array cachedCovers{}; + size_t coverCacheCapacity = 0; + size_t coverCacheUsed = 0; + int coverCacheOrientation = -1; + bool coverCacheInverted = false; + + HalFile coverFile; + Bitmap coverBitmap{coverFile}; + GfxRenderer& renderer; +}; diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 6dfe2424241..dcaf88aa969 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -66,13 +67,34 @@ void UITheme::reload() { setTheme(themeType); } +bool UITheme::supportsCoverGrid() { +#if defined(SIMULATOR) + return +#if defined(SIMULATOR_DEVICE_STICKY) || defined(SIMULATOR_DEVICE_X4_PRO) || defined(SIMULATOR_DEVICE_X4_CLASSIC) + true; +#else + false; +#endif +#else + return psramHeapAvailable(); +#endif +} + +bool UITheme::hasCoverGridHome() { + return SETTINGS.uiTheme == CrossPointSettings::UI_THEME::COVER_GRID && supportsCoverGrid(); +} + void UITheme::setTheme(CrossPointSettings::UI_THEME type) { + if (type == CrossPointSettings::UI_THEME::COVER_GRID && !supportsCoverGrid()) { + type = CrossPointSettings::UI_THEME::LYRA; + } switch (type) { case CrossPointSettings::UI_THEME::CLASSIC: LOG_DBG("UI", "Using Classic theme"); currentTheme = std::make_unique(); currentMetrics = &BaseMetrics::values; break; + case CrossPointSettings::UI_THEME::COVER_GRID: case CrossPointSettings::UI_THEME::LYRA: LOG_DBG("UI", "Using Lyra theme"); currentTheme = std::make_unique(); diff --git a/src/components/UITheme.h b/src/components/UITheme.h index 11cbb8a3f3b..29eee353f8e 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -32,6 +32,8 @@ class UITheme { EpdFontFamily::Style style = EpdFontFamily::REGULAR, int lineSpacing = 0); void reload(); void setTheme(CrossPointSettings::UI_THEME type); + static bool supportsCoverGrid(); + static bool hasCoverGridHome(); static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, bool hasSubtitle, int extraReservedHeight = 0); // Returns the cache path for a generated thumbnail using the default 3:5 diff --git a/src/components/themes/dashboard/DashboardTheme.cpp b/src/components/themes/dashboard/DashboardTheme.cpp index 9fc4439d93f..7ce1e68afc4 100644 --- a/src/components/themes/dashboard/DashboardTheme.cpp +++ b/src/components/themes/dashboard/DashboardTheme.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -16,8 +17,10 @@ #include #include +#include "CrossPointSettings.h" #include "RecentBooksStore.h" #include "activities/reader/BookReadingStats.h" +#include "activities/reader/BookStatsTracking.h" #include "activities/reader/GlobalReadingStats.h" #include "activities/reader/ReadingStatsUtils.h" #include "components/TouchRegistry.h" @@ -247,7 +250,7 @@ void drawStatsRow(const GfxRenderer& renderer, const int rightX, const int y, co } void drawDashboardStats(const GfxRenderer& renderer, const Rect& coverRect, const BookReadingStats* stats, - const float progressPercent, const bool black = true) { + const float progressPercent, const bool showStats, const bool black = true) { const int rightX = renderer.getScreenWidth() - contentInset(renderer) - (gpio.deviceIsX3() ? kPairInwardShiftX3 : 0); const int blockH = statsBlockHeight(renderer); const bool showRtcStats = halClock.isAvailable(); @@ -260,6 +263,19 @@ void drawDashboardStats(const GfxRenderer& renderer, const Rect& coverRect, cons char finishDate[24]; uint32_t estimatedSeconds = 0; const bool hasEstimate = estimatedTimeLeft(bookStats, progressPercent, estimatedSeconds); + if (!showStats) { + if (hasEstimate) { + formatCompactDuration(estimatedSeconds, value, sizeof(value)); + drawStatsRow(renderer, rightX, coverRect.y + coverRect.height / 2 - blockH, value, tr(STR_TIME_LEFT_SHORT), + black); + } + if (progressPercent >= 0.0f) { + snprintf(value, sizeof(value), "%d%%", static_cast(progressPercent + 0.5f)); + const int progressY = hasEstimate ? coverRect.y + coverRect.height / 2 + blockH : coverRect.y; + drawStatsRow(renderer, rightX, progressY, value, tr(STR_STATS_PROGRESS_LBL), black); + } + return; + } ReadingStatsDateTime today; const bool hasToday = showRtcStats && getCurrentLocalReadingStatsDateTime(today); const ReadingStatsDate endDate = bookStats.isCompleted && bookStats.finishedDate.isValid() @@ -342,6 +358,15 @@ void drawDashboardStats(const GfxRenderer& renderer, const Rect& coverRect, cons bookStats.isCompleted ? tr(STR_STATS_FINISHED_DATE) : tr(STR_STATS_EST_FINISH_DATE), black); } +bool showBookStatsForPath(const std::string& path) { + if (!SETTINGS.shouldTrackReadingStats()) return false; + if (FsHelpers::hasEpubExtension(path)) + return BookStatsTracking::isBookEnabled(Epub::cachePathForFilePath(path, "/.crosspoint")); + if (FsHelpers::hasXtcExtension(path)) + return BookStatsTracking::isBookEnabled(Xtc(path, "/.crosspoint").getCachePath()); + return false; +} + bool dominantReaderTypeBucket(const GlobalReadingStats& globalStats, ReadingTimeBucket& bucketOut) { const auto& values = globalStats.timeOfDaySeconds; const uint32_t totalSeconds = std::accumulate(values.begin(), values.end(), 0u); @@ -562,9 +587,9 @@ void DashboardTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const } TouchRegistry::getInstance().add(coverRect, 0, TouchRegistry::Cover); - drawDashboardStats(renderer, coverRect, stats, progressPercent); + drawDashboardStats(renderer, coverRect, stats, progressPercent, showBookStatsForPath(recentBooks[0].path)); drawBookText(renderer, coverRect, recentBooks[0], currentChapterTitle); - drawFooterStats(renderer, coverRect, globalStats); + if (SETTINGS.shouldTrackReadingStats()) drawFooterStats(renderer, coverRect, globalStats); } void DashboardTheme::drawSleepScreen(const GfxRenderer& renderer, const RecentBook& book, const BookReadingStats* stats, @@ -576,7 +601,7 @@ void DashboardTheme::drawSleepScreen(const GfxRenderer& renderer, const RecentBo DashboardMetrics::values.homeCoverTileHeight}; const Rect coverRect = coverRectForScreen(renderer, contentRect); drawBookCover(renderer, coverRect, book, inverted ? Color::White : Color::Black); - drawDashboardStats(renderer, coverRect, stats, progressPercent, inverted); + drawDashboardStats(renderer, coverRect, stats, progressPercent, showBookStatsForPath(book.path), inverted); drawBookText(renderer, coverRect, book, currentChapterTitle, inverted); - drawFooterStats(renderer, coverRect, globalStats, !inverted); + if (SETTINGS.shouldTrackReadingStats()) drawFooterStats(renderer, coverRect, globalStats, !inverted); } diff --git a/src/images/LoadingIcon.h b/src/images/LoadingIcon.h deleted file mode 100644 index c54c857daac..00000000000 --- a/src/images/LoadingIcon.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once -#include - -// Image dimensions: 48x48, diagonal dots -#define LOADINGICON_WIDTH 48 -#define LOADINGICON_HEIGHT 48 - -static const uint8_t LoadingIcon[] = { - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; diff --git a/src/main.cpp b/src/main.cpp index 25654c95717..482a8701bb1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -53,6 +53,7 @@ #include "activities/home/BookActions.h" #include "activities/reader/KOReaderSyncActivity.h" #include "activities/reader/ReaderProgressShadow.h" +#include "activities/reader/ReaderUtils.h" #include "activities/reader/ReadingStatsUtils.h" #include "activities/reader/StatsBackup.h" #include "activities/settings/FontDownloadActivity.h" @@ -71,7 +72,6 @@ #include "simulator/SimulatorHomeKeyInput.h" #include "simulator/SimulatorSmokeTest.h" #endif -#include "images/LoadingIcon.h" #include "util/BatteryDiagnosticLog.h" #include "util/ButtonNavigator.h" #include "util/ButtonShortcutController.h" @@ -549,8 +549,11 @@ bool handleGlobalPowerButtonAction(const CrossPointSettings::SHORT_PWRBTN action const QuickLockTrigger quickLockTrigger) { switch (action) { case CrossPointSettings::SHORT_PWRBTN::SLEEP: + case CrossPointSettings::SHORT_PWRBTN::SLEEP_ONLY: enterDeepSleep(); return true; + case CrossPointSettings::SHORT_PWRBTN::WAKE_ONLY: + return true; case CrossPointSettings::SHORT_PWRBTN::QUICK_LOCK: if (quickLockTrigger == QuickLockTrigger::None) { LOG_ERR("MAIN", "Quick Lock requested without an input trigger"); @@ -638,6 +641,7 @@ bool handleGlobalPowerButtonAction(const CrossPointSettings::SHORT_PWRBTN action } bool dispatchShortcutAction(const CrossPointSettings::SHORT_PWRBTN action) { + if (action == CrossPointSettings::SHORT_PWRBTN::READING_STATS && !SETTINGS.shouldTrackReadingStats()) return false; // An EPUB reader may have a per-book orientation that is restored during // teardown. Let it hand off Sync Progress before the global restart drops // that transient setting. @@ -1002,7 +1006,7 @@ bool shouldClearX4WakeGhosting() { // Wake validation runs before the SD card and its settings file are available. // Mirror the one setting that changes its behavior while entering sleep, so a -// deliberate short sleep press can wake the device even after the button has +// permitted short wake press can pass verification even after the button has // been released during boot. The write is skipped when the value is unchanged. constexpr char WAKE_NVS_NAMESPACE[] = "crosspoint"; constexpr char WAKE_SHORT_PRESS_KEY[] = "wakeShortPr"; @@ -1022,7 +1026,7 @@ bool readWakeShortPressFromNvs() { void mirrorWakeShortPressToNvs() { #ifndef SIMULATOR - const uint8_t expected = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP ? 1 : 0; + const uint8_t expected = SETTINGS.shortPowerPressWakes() ? 1 : 0; nvs_handle_t handle; if (nvs_open(WAKE_NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) return; uint8_t current = 0; @@ -1072,7 +1076,7 @@ void enterDeepSleep(bool fromTimeout) { Storage.remove(SLEEP_FRAME_FILE); } - if (halClock.isAvailable() && SETTINGS.autoBackupStats != 0) { + if (halClock.isAvailable() && SETTINGS.shouldTrackReadingStats() && SETTINGS.autoBackupStats != 0) { ReadingStatsDateTime now; if (getCurrentLocalReadingStatsDateTime(now) && !backupGlobalStats(false)) { LOG_ERR("MAIN", "Automatic reading-stats backup failed before deep sleep"); @@ -1234,7 +1238,8 @@ void setup() { const auto wakeupReason = gpio.getWakeupReason(); #ifndef SIMULATOR const bool shortPressWakes = readWakeShortPressFromNvs(); - if (wakeupReason == HalGPIO::WakeupReason::PowerButton && !gpio.verifyPowerButtonWakeup(shortPressWakes)) { + if (wakeupReason == HalGPIO::WakeupReason::PowerButton && + !gpio.verifyPowerButtonWakeup(shortPressWakes, CrossPointSettings::POWER_BUTTON_LONG_PRESS_MS)) { LOG_DBG("MAIN", "Power-button wake not held through verification, sleeping"); powerManager.startDeepSleep(gpio); } @@ -1405,6 +1410,7 @@ void setup() { const bool shouldRestoreSleepFrame = resume == BootResume::SplashlessWake && (isUc8279X3 ? hasValidSleepFrame : Storage.exists(SLEEP_FRAME_FILE)); bool allowFastInitialReaderRefresh = false; + bool x4WakeFrameAlreadyCleaned = false; setupDisplayAndFonts(SleepWakePolicy::shouldInitializeSeamlessly(resume, isUc8279X3, hasValidSleepFrame), resume != BootResume::Network, useReaderRenderStack); @@ -1421,25 +1427,11 @@ void setup() { break; case BootResume::SplashlessWake: if (shouldRestoreSleepFrame && loadSleepFrameBuffer()) { - const bool useDifferentialRefresh = gpio.deviceIsX3(); - if (useDifferentialRefresh) { - // begin() clears the X3 controller RAM, so restore the saved frame as - // the baseline before replacing the moon with the loading icon. + if (gpio.deviceIsX3()) { + // begin() clears the X3 controller RAM. Restore the saved frame as + // the baseline for the first reader paint without refreshing the panel. renderer.cleanupGrayscaleWithFrameBuffer(); - } - - const auto pageHeight = renderer.getScreenHeight(); - renderer.drawImage(LoadingIcon, 0, pageHeight - LOADINGICON_HEIGHT, LOADINGICON_WIDTH, LOADINGICON_HEIGHT); - if (useDifferentialRefresh) { - renderer.displayGrayscaleBase(HalDisplay::FAST_REFRESH); allowFastInitialReaderRefresh = true; - } else { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - if (shouldClearX4WakeGhosting()) { - // The X4's explicit wake refresh has already cleaned the retained - // frame, so the reader can use its fast initial cycle as well. - allowFastInitialReaderRefresh = true; - } } } else if (isUc8279X3 && hasValidSleepFrame) { // The frame passed the size preflight but could not be read after display @@ -1457,6 +1449,7 @@ void setup() { // baseline, so the reader's first page can use its fast initial cycle // instead of repeating the cleanup waveform. allowFastInitialReaderRefresh = true; + x4WakeFrameAlreadyCleaned = true; } break; case BootResume::Splash: @@ -1561,7 +1554,12 @@ void setup() { mappedInputManager.isPressed(MappedInputManager::Button::Back) || APP_STATE.readerActivityLoadCount > 0) { // Boot to home screen if no book is open, last sleep was not from reader, back button is held, or reader activity // crashed (indicated by readerActivityLoadCount > 0) - activityManager.goHome(); + // On X4, use the first Home paint to clean the retained sleep image. + const auto homeRefreshMode = + resume == BootResume::SplashlessWake && shouldClearX4WakeGhosting() && !x4WakeFrameAlreadyCleaned + ? HalDisplay::HALF_REFRESH + : HalDisplay::FAST_REFRESH; + activityManager.goHome(HomeMenuItem::NONE, homeRefreshMode); } else { // Clear app state to avoid getting into a boot loop if the epub doesn't load const auto path = APP_STATE.openEpubPath; @@ -1719,7 +1717,8 @@ void loop() { renderer.setFadingFix(SETTINGS.fadingFix); - if (Serial && millis() - lastMemPrint >= 2000) { + // ROM logging works even when Arduino Serial reports no connected listener. + if ((Serial || FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF) && millis() - lastMemPrint >= 2000) { logMemoryStats("Periodic", true); lastMemPrint = millis(); } @@ -1813,6 +1812,19 @@ void loop() { lastActivityTime = millis(); return; } + if (buttonShortcutController.tryUnlockSide(millis(), mappedInputManager.isPressed(MappedInputManager::Button::Up), + mappedInputManager.wasPressed(MappedInputManager::Button::Up), + mappedInputManager.wasReleased(MappedInputManager::Button::Up), + SETTINGS.sideButtonUpLong != CrossPointSettings::IGNORE, + mappedInputManager.isPressed(MappedInputManager::Button::Down), + mappedInputManager.wasPressed(MappedInputManager::Button::Down), + mappedInputManager.wasReleased(MappedInputManager::Button::Down), + SETTINGS.sideButtonDownLong != CrossPointSettings::IGNORE, + ReaderUtils::SKIP_HOLD_MS)) { + notifyQuickLockChanged(); + lastActivityTime = millis(); + return; + } if (handleX4ProHomeKeyQuickLockUnlock()) { lastActivityTime = millis(); return; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 9e265636477..b9683c194a0 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -88,6 +88,14 @@ bool isWebEnumOptionAvailable(const SettingInfo& setting, size_t optionIndex) { if (optionIndex >= setting.enumValues.size()) return true; const StrId option = setting.enumValues[optionIndex]; + if (!SETTINGS.shouldTrackReadingStats()) { + if (option == StrId::STR_READING_STATS) return false; + if (setting.valuePtr == &CrossPointSettings::sleepScreen && optionIndex < setting.enumRawValues.size()) { + const uint8_t raw = setting.enumRawValues[optionIndex]; + if (raw == CrossPointSettings::READING_STATS_SLEEP || raw == CrossPointSettings::MINIMAL_STATS_SLEEP) + return false; + } + } if (option == StrId::STR_TOGGLE_TOUCHSCREEN && !gpio.hasTouch()) return false; if (!Frontlight.present()) { @@ -1781,7 +1789,12 @@ void CrossPointWebServer::handleGetSettings() const { doc.clear(); doc["key"] = s.key; - doc["name"] = I18N.get(s.nameId); + if (isSideButtonActionSetting(s)) { + const bool up = settingKeyIs(s, "sideButtonUpShort") || settingKeyIs(s, "sideButtonUpLong"); + doc["name"] = sideButtonGroupLabel(up) + " " + I18N.get(s.nameId); + } else { + doc["name"] = I18N.get(s.nameId); + } doc["category"] = I18N.get(s.category); switch (s.type) { @@ -1794,6 +1807,12 @@ void CrossPointWebServer::handleGetSettings() const { } case SettingType::ENUM: { doc["type"] = "enum"; + if (s.valuePtr == &CrossPointSettings::shortPwrBtn || s.valuePtr == &CrossPointSettings::longPwrBtn) { + doc["footnotesIndex"] = enumDisplayIndexForWeb(s, CrossPointSettings::FOOTNOTES); + } else if (s.valuePtr == &CrossPointSettings::longPressMenuAction || + s.valuePtr == &CrossPointSettings::longPressBackAction) { + doc["footnotesIndex"] = enumDisplayIndexForWeb(s, CrossPointSettings::LONG_MENU_FOOTNOTES); + } if (s.nameId == StrId::STR_FONT_FAMILY && !fontFamilies.empty()) { uint8_t selected = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0; if (selectedSdFamily) { @@ -1838,7 +1857,7 @@ void CrossPointWebServer::handleGetSettings() const { } else { for (size_t optionIndex = 0; optionIndex < s.enumValues.size(); ++optionIndex) { if (isWebEnumOptionAvailable(s, optionIndex)) { - options.add(I18N.get(s.enumValues[optionIndex])); + options.add(sideButtonOptionLabel(s, static_cast(optionIndex))); } } } diff --git a/src/simulator/SimulatorSmokeTest.cpp b/src/simulator/SimulatorSmokeTest.cpp index c56e06b7a6f..6c16bdb82a9 100644 --- a/src/simulator/SimulatorSmokeTest.cpp +++ b/src/simulator/SimulatorSmokeTest.cpp @@ -35,10 +35,12 @@ #include "activities/reader/ReaderFontLoading.h" #include "activities/reader/ReaderOptionsActivity.h" #include "activities/reader/ReaderUtils.h" +#include "activities/reader/SideButtonShortcuts.h" #include "activities/settings/QuickActionsActivity.h" #include "components/TouchHeaderBackButton.h" #include "components/UITheme.h" #include "simulator/SimulatorHomeKeyInput.h" +#include "util/ButtonShortcutController.h" extern ActivityManager activityManager; extern GfxRenderer renderer; @@ -53,6 +55,7 @@ enum class SmokeStep : uint8_t { FileBrowserSettings, Library, Settings, + SideButtons, ReaderOptions, ReaderMenu, Sleep, @@ -373,6 +376,7 @@ class SimulatorSmokeTest { SETTINGS.librarySortMethod = 3; SETTINGS.librarySortDescending = 0; SETTINGS.libraryListExpanded = 1; + SETTINGS.recentBooksView = CrossPointSettings::RECENT_BOOKS_GRID; SETTINGS.libraryShowMarkdown = 0; SETTINGS.libraryHideFinishedBooks = 1; JsonDocument librarySaved; @@ -380,20 +384,29 @@ class SimulatorSmokeTest { SETTINGS.librarySortMethod = 0; SETTINGS.librarySortDescending = 1; SETTINGS.libraryListExpanded = 0; + SETTINGS.recentBooksView = CrossPointSettings::RECENT_BOOKS_LIST; SETTINGS.libraryShowMarkdown = 1; SETTINGS.libraryHideFinishedBooks = 0; SETTINGS.fromJson(librarySaved.as()); if (SETTINGS.librarySortMethod != 3 || SETTINGS.librarySortDescending || !SETTINGS.libraryListExpanded || - SETTINGS.libraryShowMarkdown || !SETTINGS.libraryHideFinishedBooks) { + SETTINGS.recentBooksView != CrossPointSettings::RECENT_BOOKS_GRID || SETTINGS.libraryShowMarkdown || + !SETTINGS.libraryHideFinishedBooks) { fail("Library settings round-trip mismatch"); } librarySaved["librarySortMethod"] = 99; librarySaved["libraryShowTxt"] = 2; librarySaved["libraryHideFinishedBooks"] = 2; + librarySaved["recentBooksView"] = 2; SETTINGS.fromJson(librarySaved.as()); - if (SETTINGS.librarySortMethod != 3 || SETTINGS.libraryShowTxt != 1 || !SETTINGS.libraryHideFinishedBooks) { + if (SETTINGS.librarySortMethod != 3 || SETTINGS.libraryShowTxt != 1 || !SETTINGS.libraryHideFinishedBooks || + SETTINGS.recentBooksView != CrossPointSettings::RECENT_BOOKS_LIST) { fail("Invalid Library settings were not rejected"); } + librarySaved.remove("recentBooksView"); + SETTINGS.recentBooksView = CrossPointSettings::RECENT_BOOKS_GRID; + SETTINGS.fromJson(librarySaved.as()); + if (SETTINGS.recentBooksView != CrossPointSettings::RECENT_BOOKS_LIST) + fail("Missing Recently Opened view did not default to List"); SETTINGS.fromJson(original.as()); } @@ -416,10 +429,6 @@ class SimulatorSmokeTest { return settingKeyIs(setting, "powerChordAction"); }); if (chordSetting == allSettings.end()) fail("Power chord setting is missing"); - if (std::find(chordSetting->enumRawValues.begin(), chordSetting->enumRawValues.end(), - CrossPointSettings::CHORD_SLEEP) != chordSetting->enumRawValues.end()) { - fail("Sleep is still offered for a chord that cannot wake the device"); - } if (std::find(chordSetting->enumRawValues.begin(), chordSetting->enumRawValues.end(), CrossPointSettings::CHORD_QUICK_ACTIONS) == chordSetting->enumRawValues.end()) { fail("Quick Actions is missing from the Power + Up chord setting"); @@ -459,13 +468,73 @@ class SimulatorSmokeTest { }; verifyLibraryChoice("shortPwrBtn", ShortcutOptionCatalog::PowerButton); verifyLibraryChoice("longPwrBtn", ShortcutOptionCatalog::PowerButton); + for (const char* key : {"sideButtonUpShort", "sideButtonUpLong", "sideButtonDownShort", "sideButtonDownLong"}) { + verifyLibraryChoice(key, ShortcutOptionCatalog::SideButton); + } verifyLibraryChoice("powerChordAction", ShortcutOptionCatalog::ButtonChord); verifyLibraryChoice("longPressMenuAction", ShortcutOptionCatalog::LongPress); verifyLibraryChoice("longPressBackAction", ShortcutOptionCatalog::LongPress); + const auto verifySleepChoice = [&](const char* key, const ShortcutOptionCatalog catalog) { + const auto setting = std::find_if(allSettings.begin(), allSettings.end(), + [key](const SettingInfo& candidate) { return settingKeyIs(candidate, key); }); + const auto raw = shortcutRawValue(catalog, CrossPointSettings::SLEEP); + if (setting == allSettings.end() || raw == SHORTCUT_OPTION_UNAVAILABLE) { + fail("Sleep shortcut is missing from %s", key); + } + const auto choice = std::find(setting->enumRawValues.begin(), setting->enumRawValues.end(), raw); + const std::string expected = catalog == ShortcutOptionCatalog::PowerButton + ? std::string(tr(STR_SLEEP)) + "/" + tr(STR_WAKE) + : tr(STR_SLEEP); + if (choice == setting->enumRawValues.end() || + sideButtonOptionLabel(*setting, static_cast(choice - setting->enumRawValues.begin())) != expected) { + fail("Sleep shortcut has the wrong label in %s", key); + } + }; + verifySleepChoice("shortPwrBtn", ShortcutOptionCatalog::PowerButton); + verifySleepChoice("longPwrBtn", ShortcutOptionCatalog::PowerButton); + for (const char* key : {"shortPwrBtn", "longPwrBtn"}) { + const auto setting = std::find_if(allSettings.begin(), allSettings.end(), + [key](const SettingInfo& candidate) { return settingKeyIs(candidate, key); }); + if (setting == allSettings.end()) fail("Power shortcut setting is missing: %s", key); + for (const auto [action, label] : {std::pair{CrossPointSettings::SLEEP_ONLY, StrId::STR_SLEEP}, + std::pair{CrossPointSettings::WAKE_ONLY, StrId::STR_WAKE}}) { + const auto choice = std::find(setting->enumRawValues.begin(), setting->enumRawValues.end(), action); + if (choice == setting->enumRawValues.end() || + sideButtonOptionLabel(*setting, static_cast(choice - setting->enumRawValues.begin())) != + I18N.get(label)) { + fail("Power-only shortcut is missing or mislabeled in %s", key); + } + } + } + const uint8_t savedShortPowerAction = SETTINGS.shortPwrBtn; + for (const auto [action, wakes] : + {std::pair{CrossPointSettings::IGNORE, false}, std::pair{CrossPointSettings::SLEEP_ONLY, false}, + std::pair{CrossPointSettings::WAKE_ONLY, true}, std::pair{CrossPointSettings::SLEEP, true}}) { + SETTINGS.shortPwrBtn = action; + if (SETTINGS.shortPowerPressWakes() != wakes) fail("Short Power wake policy does not match its shortcut"); + } + SETTINGS.shortPwrBtn = savedShortPowerAction; + verifySleepChoice("powerChordAction", ShortcutOptionCatalog::ButtonChord); + verifySleepChoice("longPressMenuAction", ShortcutOptionCatalog::LongPress); + verifySleepChoice("longPressBackAction", ShortcutOptionCatalog::LongPress); + for (const char* key : {"sideButtonUpShort", "sideButtonUpLong", "sideButtonDownShort", "sideButtonDownLong"}) { + const auto side = std::find_if(sideButtonSettings.begin(), sideButtonSettings.end(), + [key](const SettingInfo& candidate) { return settingKeyIs(candidate, key); }); + if (side != sideButtonSettings.end()) { + const auto sleep = std::find(side->enumRawValues.begin(), side->enumRawValues.end(), CrossPointSettings::SLEEP); + if (sleep == side->enumRawValues.end() || + sideButtonOptionLabel(*side, static_cast(sleep - side->enumRawValues.begin())) != tr(STR_SLEEP)) { + fail("Sleep shortcut has the wrong label in %s", key); + } + } + } if (gpio.hasHomeKey()) { verifyLibraryChoice("homeButtonTapAction", ShortcutOptionCatalog::HomeButton); verifyLibraryChoice("homeButtonDoubleTapAction", ShortcutOptionCatalog::HomeButton); verifyLibraryChoice("homeButtonLongPressAction", ShortcutOptionCatalog::HomeButton); + verifySleepChoice("homeButtonTapAction", ShortcutOptionCatalog::HomeButton); + verifySleepChoice("homeButtonDoubleTapAction", ShortcutOptionCatalog::HomeButton); + verifySleepChoice("homeButtonLongPressAction", ShortcutOptionCatalog::HomeButton); } if (hasSideButtonChord) { const auto side = @@ -474,11 +543,122 @@ class SimulatorSmokeTest { if (side == sideButtonSettings.end() || !hasLibrary(*side, ShortcutOptionCatalog::ButtonChord)) { fail("Library shortcut is missing from the filtered Up + Down choices"); } + const auto sleep = + std::find(side->enumRawValues.begin(), side->enumRawValues.end(), CrossPointSettings::CHORD_SLEEP); + if (sleep == side->enumRawValues.end() || + sideButtonOptionLabel(*side, static_cast(sleep - side->enumRawValues.begin())) != tr(STR_SLEEP)) { + fail("Sleep shortcut is missing or mislabeled in 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"); } + + const uint8_t savedTrackReadingStats = SETTINGS.trackReadingStats; + SETTINGS.trackReadingStats = 0; + const auto statsDisabledSettings = getSettingsList(); + SETTINGS.trackReadingStats = savedTrackReadingStats; + for (const char* key : {"sideButtonUpShort", "sideButtonUpLong", "sideButtonDownShort", "sideButtonDownLong"}) { + const auto setting = std::find_if(statsDisabledSettings.begin(), statsDisabledSettings.end(), + [key](const SettingInfo& candidate) { return settingKeyIs(candidate, key); }); + if (setting == statsDisabledSettings.end() || + std::find(setting->enumRawValues.begin(), setting->enumRawValues.end(), CrossPointSettings::READING_STATS) != + setting->enumRawValues.end()) { + fail("Reading Stats remains available in %s while tracking is disabled", key); + } + } + } + + static void verifySideButtonMigrationAndInput() { + JsonDocument original; + SETTINGS.toJson(original); + JsonDocument legacy; + SETTINGS.toJson(legacy); + for (const char* key : {"sideButtonUpShort", "sideButtonUpLong", "sideButtonDownShort", "sideButtonDownLong"}) { + legacy[key] = nullptr; + } + legacy["sideButtonLayout"] = CrossPointSettings::NEXT_PREV; + legacy["sideButtonLongPress"] = CrossPointSettings::SIDE_LONG_CHAPTER_SKIP; + SETTINGS.fromJson(legacy.as()); + if (SETTINGS.sideButtonUpShort != CrossPointSettings::PAGE_TURN || + SETTINGS.sideButtonDownShort != CrossPointSettings::PREVIOUS_PAGE || + SETTINGS.sideButtonUpLong != CrossPointSettings::SIDE_NEXT_CHAPTER || + SETTINGS.sideButtonDownLong != CrossPointSettings::SIDE_PREVIOUS_CHAPTER) + fail("Swapped side-button layout migration failed"); + + legacy["sideButtonLayout"] = CrossPointSettings::SIDE_BUTTONS_DISABLED; + legacy["sideButtonLongPress"] = CrossPointSettings::SIDE_LONG_FONT_SIZE; + SETTINGS.fromJson(legacy.as()); + if (SETTINGS.sideButtonUpShort != CrossPointSettings::IGNORE || + SETTINGS.sideButtonDownShort != CrossPointSettings::IGNORE || + SETTINGS.sideButtonUpLong != CrossPointSettings::SIDE_INCREASE_FONT || + SETTINGS.sideButtonDownLong != CrossPointSettings::SIDE_DECREASE_FONT) + fail("Disabled layout with font hold migration failed"); + + legacy["sideButtonUpShort"] = CrossPointSettings::LIBRARY; + SETTINGS.fromJson(legacy.as()); + if (SETTINGS.sideButtonUpShort != CrossPointSettings::LIBRARY || + SETTINGS.sideButtonDownShort != CrossPointSettings::IGNORE) + fail("Partially migrated side-button choices were overwritten"); + + SETTINGS.fromJson(original.as()); + const auto sideSettings = buildControlsSideButtonSettingsList(getSettingsList()); + const size_t expected = 7 + (deviceSupportsSideButtonChord(gpio) ? 1u : 0u); + if (sideSettings.size() != expected) fail("Side-button menu row count mismatch"); + for (const char* key : {"sideButtonUpShort", "sideButtonUpLong", "sideButtonDownShort", "sideButtonDownLong"}) { + if (std::find_if(sideSettings.begin(), sideSettings.end(), + [key](const SettingInfo& setting) { return settingKeyIs(setting, key); }) == sideSettings.end()) + fail("Side-button menu is missing an individual shortcut"); + } + + SideButtonShortcuts shortcuts; + mappedInputManager.simulatorInjectPress(MappedInputManager::Button::Up); + const auto heldUp = shortcuts.update(mappedInputManager, 1000); + if (!heldUp.consumed) fail("Side-button press was not captured"); + mappedInputManager.injectRelease(MappedInputManager::Button::Right); + if (SideButtonShortcuts::shouldConsume(heldUp, mappedInputManager)) + fail("Side-button hold swallowed a chord page turn"); + mappedInputManager.clearInjectedReleases(); + mappedInputManager.simulatorClearInputFrame(); + mappedInputManager.simulatorInjectRelease(MappedInputManager::Button::Up); + const auto shortResult = shortcuts.update(mappedInputManager, 1200); + if (!shortResult.triggered || shortResult.longPress || shortResult.action != SETTINGS.sideButtonUpShort) + fail("Side-button short action did not dispatch once"); + mappedInputManager.simulatorClearInputFrame(); + if (shortcuts.update(mappedInputManager, 1300).triggered) fail("Side-button release dispatched twice"); + + mappedInputManager.simulatorInjectPress(MappedInputManager::Button::Down); + shortcuts.update(mappedInputManager, 2000); + mappedInputManager.simulatorClearInputFrame(); + const auto longResult = shortcuts.update(mappedInputManager, 2800); + if (!longResult.triggered || !longResult.longPress || longResult.action != SETTINGS.sideButtonDownLong) + fail("Side-button long action did not dispatch"); + mappedInputManager.suppressNextSideRelease(MappedInputManager::Button::Down); + mappedInputManager.simulatorInjectRelease(MappedInputManager::Button::Down); + if (mappedInputManager.wasReleased(MappedInputManager::Button::Down) || + shortcuts.update(mappedInputManager, 2900).triggered) + fail("Side-button long release leaked into the next activity"); + mappedInputManager.simulatorClearInputFrame(); + if (shortcuts.update(mappedInputManager, 3000).triggered) fail("Side-button long action dispatched twice"); + + ButtonShortcutController lock; + lock.toggleQuickLock(4000, QuickLockTrigger::SideUpShort); + if (lock.tryUnlockSide(4100, true, true, false, true, false, false, false, true, ReaderUtils::SKIP_HOLD_MS) || + !lock.isQuickLocked()) + fail("Side-button Quick Lock unlocked on press instead of release"); + if (!lock.tryUnlockSide(4200, false, false, true, true, false, false, false, true, ReaderUtils::SKIP_HOLD_MS) || + lock.isQuickLocked()) + fail("Side-button short Quick Lock did not unlock on a fresh release"); + lock.toggleQuickLock(5000, QuickLockTrigger::SideDownLong); + if (lock.tryUnlockSide(5100, false, false, false, true, true, false, false, true, ReaderUtils::SKIP_HOLD_MS)) + fail("An inherited side-button hold unlocked Quick Lock"); + lock.tryUnlockSide(5200, false, false, false, true, false, false, true, true, ReaderUtils::SKIP_HOLD_MS); + lock.tryUnlockSide(5300, false, false, false, true, true, true, false, true, ReaderUtils::SKIP_HOLD_MS); + if (!lock.tryUnlockSide(6000, false, false, false, true, true, false, false, true, ReaderUtils::SKIP_HOLD_MS) || + lock.isQuickLocked()) + fail("Side-button long Quick Lock did not unlock on a fresh hold"); + LOG_INF("SMOKE", "Side-button migration and press/release checks passed"); } [[noreturn]] static void fail(const char* message) { @@ -576,6 +756,7 @@ class SimulatorSmokeTest { fail("Simulator Home key timing contract failed"); } verifyUpDownShortcutAvailability(); + verifySideButtonMigrationAndInput(); verifyReaderControlsSettings(); verifyMixedPageGestures(); #if CROSSINK_SCALABLE_FONTS @@ -844,6 +1025,22 @@ class SimulatorSmokeTest { case SmokeStep::Settings: renderer.setOrientation(GfxRenderer::Orientation::Portrait); + if (!mappedInputManager.hasTouchHardware()) { + inputScript.clear(); + scriptIndex = 0; + inputCompletionStep = SmokeStep::SideButtons; + addTap(MappedInputManager::Button::Confirm); // Reader tab + addTap(MappedInputManager::Button::Confirm); // Controls tab + addTap(MappedInputManager::Button::Down); // Power Button + addTap(MappedInputManager::Button::Down); // Front Buttons + addTap(MappedInputManager::Button::Down); // Side Buttons + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("Side Button Settings", 250)); + step = SmokeStep::ReaderInput; + break; + } + [[fallthrough]]; + case SmokeStep::SideButtons: activityManager.replaceActivity(std::make_unique(renderer, mappedInputManager)); queueStep("Reader Options", SmokeStep::ReaderOptions); break; @@ -851,7 +1048,7 @@ class SimulatorSmokeTest { case SmokeStep::ReaderOptions: 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)); + false, false, false, false, true, true, 0, 0, 5, false)); queueStep("Reader Menu", SmokeStep::ReaderMenu); break; diff --git a/src/util/BookCacheUtils.cpp b/src/util/BookCacheUtils.cpp index 748acb257e7..4a84a5c850f 100644 --- a/src/util/BookCacheUtils.cpp +++ b/src/util/BookCacheUtils.cpp @@ -23,14 +23,17 @@ constexpr PreservedCacheFile EPUB_USER_STATE_FILES[] = { {"progress.bin.bak", "upload_preserve_progress.bin.bak"}, {"reader_settings.bin", "upload_preserve_reader_settings.bin"}, {"dictionary_history.txt", "upload_preserve_dictionary_history.txt"}, + {"reading_stats_off", "upload_preserve_reading_stats_off"}, }; constexpr PreservedCacheFile PAGE_PROGRESS_FILES[] = { {"progress.bin", "upload_preserve_progress.bin"}, + {"reading_stats_off", "upload_preserve_reading_stats_off"}, }; constexpr PreservedCacheFile CACHE_CLEAR_USER_STATE_FILES[] = { {"dictionary_history.txt", "clear_preserve_dictionary_history.txt"}, + {"reading_stats_off", "clear_preserve_reading_stats_off"}, }; struct ResolvedPreservedCacheFile { diff --git a/src/util/ButtonShortcutController.h b/src/util/ButtonShortcutController.h index 1bc932885e2..3cf572ab5b9 100644 --- a/src/util/ButtonShortcutController.h +++ b/src/util/ButtonShortcutController.h @@ -119,6 +119,17 @@ class ButtonShortcutController { const bool locked = quickLockState_.toggle(nowMs); quickLockTrigger_ = locked ? trigger : QuickLockTrigger::None; unlockTriggerReleased_ = !locked || !requireRelease; + sideUpUnlock_ = {}; + sideDownUnlock_ = {}; + } + bool tryUnlockSide(uint32_t nowMs, bool upPressed, bool upPressEdge, bool upReleaseEdge, bool upHasLongAction, + bool downPressed, bool downPressEdge, bool downReleaseEdge, bool downHasLongAction, + uint32_t longPressMs) { + if (!quickLockState_.isLocked()) return false; + return tryUnlockOneSide(nowMs, upPressed, upPressEdge, upReleaseEdge, upHasLongAction, longPressMs, + QuickLockTrigger::SideUpShort, QuickLockTrigger::SideUpLong, sideUpUnlock_) || + tryUnlockOneSide(nowMs, downPressed, downPressEdge, downReleaseEdge, downHasLongAction, longPressMs, + QuickLockTrigger::SideDownShort, QuickLockTrigger::SideDownLong, sideDownUnlock_); } bool tryUnlockLongPower(uint32_t nowMs, bool longPowerPressed) { if (!quickLockState_.isLocked() || quickLockTrigger_ != QuickLockTrigger::LongPower) return false; @@ -143,6 +154,33 @@ class ButtonShortcutController { } private: + struct SideUnlockState { + uint32_t pressedAt = 0; + bool tracking = false; + bool longHandled = false; + }; + + bool tryUnlockOneSide(uint32_t nowMs, bool pressed, bool pressEdge, bool releaseEdge, bool hasLongAction, + uint32_t longPressMs, QuickLockTrigger shortTrigger, QuickLockTrigger longTrigger, + SideUnlockState& state) { + if (pressEdge) { + state = {nowMs, true, false}; + } + if (!state.tracking) return false; + if (!state.longHandled && hasLongAction && nowMs - state.pressedAt >= longPressMs && (pressed || releaseEdge)) { + state.longHandled = true; + if (quickLockTrigger_ == longTrigger) { + toggleQuickLock(nowMs, longTrigger); + return true; + } + } else if (releaseEdge && !state.longHandled && quickLockTrigger_ == shortTrigger) { + toggleQuickLock(nowMs, shortTrigger); + return true; + } + if (!pressed) state.tracking = false; + return false; + } + bool canRunChordWhileQuickLocked(ChordAction action, QuickLockTrigger trigger) const { return action == ChordAction::QuickLock && quickLockTrigger_ == trigger; } @@ -169,4 +207,6 @@ class ButtonShortcutController { bool powerDownChordActive_ = false; bool upDownChordActive_ = false; bool unlockTriggerReleased_ = true; + SideUnlockState sideUpUnlock_; + SideUnlockState sideDownUnlock_; }; diff --git a/src/util/DictionaryActivityUtils.h b/src/util/DictionaryActivityUtils.h index 23cf2af8a25..24682c30762 100644 --- a/src/util/DictionaryActivityUtils.h +++ b/src/util/DictionaryActivityUtils.h @@ -18,17 +18,15 @@ inline void cancelAndFinish(Activity& act) { act.finish(); } -// Dictionary screens normally follow the configured reader side-button layout. -// Next/Next has no PageBack binding, so reserve Up/Left for previous navigation -// and keep Down/Right as next. +// Dictionary screens follow configured short page actions. When both side +// buttons turn forward, reserve Up for previous navigation in the picker. inline bool dictionaryPageButtonTriggered(MappedInputManager& input, const bool previous) { - const bool usePress = SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_OFF; - const auto sideLayout = static_cast(SETTINGS.sideButtonLayout); const MappedInputManager::Button button = - sideLayout == CrossPointSettings::NEXT_NEXT + SETTINGS.sideButtonUpShort == CrossPointSettings::PAGE_TURN && + SETTINGS.sideButtonDownShort == CrossPointSettings::PAGE_TURN ? (previous ? MappedInputManager::Button::Up : MappedInputManager::Button::Down) : (previous ? MappedInputManager::Button::PageBack : MappedInputManager::Button::PageForward); - return usePress ? input.wasPressed(button) : input.wasReleased(button); + return input.wasReleased(button); } inline void drawWordSelectButtonHints(GfxRenderer& renderer, const MappedInputManager& mappedInput, diff --git a/src/util/QuickLockTrigger.h b/src/util/QuickLockTrigger.h index d538242e6bc..e547d307863 100644 --- a/src/util/QuickLockTrigger.h +++ b/src/util/QuickLockTrigger.h @@ -14,4 +14,8 @@ enum class QuickLockTrigger : uint8_t { HomeDoubleTap, HomeTap, HomeLongPress, + SideUpShort, + SideUpLong, + SideDownShort, + SideDownLong, }; diff --git a/src/util/ReaderStatusBarJson.h b/src/util/ReaderStatusBarJson.h index 15006906945..6fdb05ad37b 100644 --- a/src/util/ReaderStatusBarJson.h +++ b/src/util/ReaderStatusBarJson.h @@ -65,12 +65,15 @@ inline bool repairReaderStatusBarJson(const JsonVariantConst source, ReaderStatu return false; } const JsonArrayConst slots = source["slots"].as(); - if (slots.size() != ReaderStatusBarConfig::SLOT_COUNT) return true; + // The previous layout had two left slots, one center slot, and three right slots. + constexpr unsigned LEGACY_SLOT_COUNT = ReaderStatusBarConfig::SLOT_COUNT - 1; + if (slots.size() != LEGACY_SLOT_COUNT && slots.size() != ReaderStatusBarConfig::SLOT_COUNT) return true; ReaderStatusBarConfig repaired; - for (unsigned i = 0; i < ReaderStatusBarConfig::SLOT_COUNT; ++i) { + for (unsigned i = 0; i < slots.size(); ++i) { const int item = slots[i].as(); if (slots[i].is() && validReaderStatusBarItemValue(item, clockAvailable)) { - repaired.slots[i] = static_cast(item); + const unsigned target = slots.size() == LEGACY_SLOT_COUNT && i >= 2 ? i + 1 : i; + repaired.slots[target] = static_cast(item); } } const auto choice = [](const JsonVariantConst value, const int count, const uint8_t fallback) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 7a504f0ea61..e9740222fcd 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -79,6 +79,7 @@ add_subdirectory(touch_reader_preview) add_subdirectory(dictionary_word_parts) add_subdirectory(ligature_guard) add_subdirectory(chapter_html_slim_parser) +add_subdirectory(korean_line_breaking) add_subdirectory(input_release_guard) add_subdirectory(release_suppression) add_subdirectory(bitmap_resample) diff --git a/test/fs_helpers/CMakeLists.txt b/test/fs_helpers/CMakeLists.txt index 435d76fce29..28e839804d6 100644 --- a/test/fs_helpers/CMakeLists.txt +++ b/test/fs_helpers/CMakeLists.txt @@ -2,11 +2,13 @@ add_executable(FsHelpersTest FsHelpersTest.cpp ${REPO_ROOT}/lib/FsHelpers/FsHelpers.cpp ${REPO_ROOT}/lib/FsHelpers/NaturalSort.cpp + ${REPO_ROOT}/lib/Utf8/Utf8.cpp ) target_include_directories(FsHelpersTest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/stubs ${REPO_ROOT}/lib/FsHelpers + ${REPO_ROOT}/lib/Utf8 ) target_link_libraries(FsHelpersTest PRIVATE diff --git a/test/fs_helpers/FsHelpersTest.cpp b/test/fs_helpers/FsHelpersTest.cpp index 82fc0aedd2b..38248aa89d0 100644 --- a/test/fs_helpers/FsHelpersTest.cpp +++ b/test/fs_helpers/FsHelpersTest.cpp @@ -1,5 +1,8 @@ #include +#include +#include + #include "FsHelpers.h" namespace { @@ -22,4 +25,41 @@ TEST(IsSafePathComponent, RejectsEmptyDotAndPathSeparators) { EXPECT_FALSE(FsHelpers::isSafePathComponent("../x"sv)); } +TEST(NormalisePath, CollapsesParentReferenceWithinPath) { + EXPECT_EQ(FsHelpers::normalisePath("/Books/../.crosspoint/x"), ".crosspoint/x"); +} + +TEST(NormalisePath, DropsLeadingParentReferencesPastRoot) { EXPECT_EQ(FsHelpers::normalisePath("/../../etc"), "etc"); } + +// Sanitizes into a buffer of `size` bytes (at most 64, the size ScreenshotUtil uses). +std::string sanitize(const char* input, const size_t size = 64) { + char out[64]; + FsHelpers::sanitizePathComponentForFat32(input, out, size); + return out; +} + +// Book titles from the EPUBs attached to #2103 and #2199. +constexpr char kTitle2103[] = "Богиня глюкозы. Нормализуйте уровень сахара в крови, чтобы изменить свою жизнь"; +constexpr char kTitle2199[] = "Вглядываясь в солнце. Жизнь без страха смерти"; + +TEST(SanitizePathComponentForFat32, KeepsTitleThatFits) { + EXPECT_EQ(sanitize("Эдем (полный перевод)"), "Эдем-(полный-перевод)"); +} + +// The readers copy the title into ScreenshotInfo::title (char[64]) with snprintf, which can +// end the copy partway through a Cyrillic letter. +TEST(SanitizePathComponentForFat32, DropsLetterCutOffByCaller) { + char title[64]; + snprintf(title, sizeof(title), "%s", kTitle2103); + EXPECT_EQ(sanitize(title), "Богиня-глюкозы.-Нормализуйте-уров"); + snprintf(title, sizeof(title), "%s", kTitle2199); + EXPECT_EQ(sanitize(title), "Вглядываясь-в-солнце.-Жизнь-без-ст"); +} + +TEST(SanitizePathComponentForFat32, DoesNotSplitLetterAtBufferLimit) { + // Each letter of "Жизнь" is 2 bytes. 7 bytes of room holds "Жиз" and half of "н". + EXPECT_EQ(sanitize("Жизнь", 8), "Жиз"); + EXPECT_EQ(sanitize(kTitle2103), "Богиня-глюкозы.-Нормализуйте-уров"); +} + } // namespace diff --git a/test/korean_line_breaking/CMakeLists.txt b/test/korean_line_breaking/CMakeLists.txt new file mode 100644 index 00000000000..616710a4a6e --- /dev/null +++ b/test/korean_line_breaking/CMakeLists.txt @@ -0,0 +1,33 @@ +add_executable(KoreanLineBreakingTest + KoreanLineBreakingTest.cpp + ${REPO_ROOT}/lib/Epub/Epub/ParsedText.cpp + ${REPO_ROOT}/lib/Epub/Epub/blocks/TextBlock.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/Hyphenator.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/LanguageRegistry.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/LiangHyphenation.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp + ${REPO_ROOT}/lib/MiniBidi/BidiUtils.cpp + ${REPO_ROOT}/lib/MiniBidi/minibidi.c + ${REPO_ROOT}/lib/Utf8/Utf8.cpp +) + +target_include_directories(KoreanLineBreakingTest PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/stubs + ${REPO_ROOT}/test/memory_policy/stubs + ${REPO_ROOT}/lib + ${REPO_ROOT}/lib/Epub + ${REPO_ROOT}/lib/Epub/Epub + ${REPO_ROOT}/lib/EpdFont + ${REPO_ROOT}/lib/Utf8 + ${REPO_ROOT}/lib/Memory + ${REPO_ROOT}/lib/Serialization + ${REPO_ROOT}/lib/MiniBidi + ${REPO_ROOT}/lib/FsHelpers +) + +target_link_libraries(KoreanLineBreakingTest PRIVATE + crosspoint_test_common + GTest::gtest_main +) + +gtest_discover_tests(KoreanLineBreakingTest) diff --git a/test/korean_line_breaking/KoreanLineBreakingTest.cpp b/test/korean_line_breaking/KoreanLineBreakingTest.cpp new file mode 100644 index 00000000000..b9e1c5e4020 --- /dev/null +++ b/test/korean_line_breaking/KoreanLineBreakingTest.cpp @@ -0,0 +1,123 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +struct Line { + std::vector words; + std::vector xpos; +}; + +// Fixture metrics (stub renderer): every glyph is 8 px wide and a space is 4 px. +std::vector layout(const std::vector& words, const bool hyphenation, const uint16_t width) { + GfxRenderer renderer; + BlockStyle style; + style.alignment = CssTextAlign::Justify; + style.textIndentDefined = true; + ParsedText text(false, false, hyphenation, false, false, 0, style); + for (const char* word : words) text.addWord(word, EpdFontFamily::REGULAR); + std::vector lines; + EXPECT_TRUE(text.layoutAndExtractLines(renderer, 0, width, [&](std::shared_ptr block, uint32_t, uint32_t) { + auto& line = lines.emplace_back(); + for (uint16_t i = 0; i < block->wordCount(); ++i) { + line.words.emplace_back(block->wordText(i)); + line.xpos.push_back(block->wordXpos(i)); + } + })); + return lines; +} + +std::vector> wordsOf(const std::vector& lines) { + std::vector> result; + result.reserve(lines.size()); + for (const auto& line : lines) result.push_back(line.words); + return result; +} + +} // namespace + +TEST(KoreanLineBreaking, HyphenationOffWrapsAtSpacesOnly) { + Hyphenator::setPreferredLanguage("ko"); + // 가나 다라마바 needs 52 px; with hyphenation off the Hangul word moves down whole. + const auto lines = layout({"가나", "다라마바", "사"}, false, 50); + const std::vector> expected{{"가나"}, {"다라마바", "사"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsBetweenSyllablesWithoutHyphen) { + Hyphenator::setPreferredLanguage("ko"); + // 가나 + space leaves 30 px: the widest syllable prefix that fits is 다라마 (24 px). + const auto lines = layout({"가나", "다라마바사아", "자"}, true, 50); + const std::vector> expected{{"가나", "다라마"}, {"바사아", "자"}}; + ASSERT_EQ(wordsOf(lines), expected); + // 16 + 4 + 24 = 44 px leaves 6 px, all of it on the one word space. + EXPECT_EQ(lines[0].xpos, (std::vector{0, 26})); +} + +TEST(KoreanLineBreaking, HyphenationOnKeepsTrailingPunctuationWithSyllable) { + Hyphenator::setPreferredLanguage("ko"); + // 가나다 + space leaves 36 px. 라마바사 (32 px) would fit, but 사. stays together, so 라마바 is used. + const auto lines = layout({"가나다", "라마바사.", "자"}, true, 64); + const std::vector> expected{{"가나다", "라마바"}, {"사.", "자"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsShortWords) { + Hyphenator::setPreferredLanguage("ko"); + // 가나다 + space leaves 22 px, so the three-syllable word splits after 라마 (16 px). + const auto lines = layout({"가나다", "라마바", "자"}, true, 50); + const std::vector> expected{{"가나다", "라마"}, {"바", "자"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsWhereHangulMeetsOtherScriptsOrBrackets) { + Hyphenator::setPreferredLanguage("ko"); + // 가 + space leaves 52 px. 소신(ab) (48 px) fits; the split may fall before "(" or after ")", + // never inside the brackets. + const auto lines = layout({"가", "소신(ab)이"}, true, 64); + const std::vector> expected{{"가", "소신(ab)"}, {"이"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsBetweenDigitAndHangul) { + Hyphenator::setPreferredLanguage("ko"); + // 가나다 + space leaves 22 px: 12 (16 px) fits, 12월 (24 px) does not. + const auto lines = layout({"가나다", "12월부터", "자"}, true, 50); + const std::vector> expected{{"가나다", "12"}, {"월부터", "자"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, JustifiedWordsKeepNaturalSyllableSpacing) { + Hyphenator::setPreferredLanguage("ko"); + const auto lines = layout({"가나", "다라", "마바", "사아"}, false, 60); + const std::vector> expected{{"가나", "다라", "마바"}, {"사아"}}; + EXPECT_EQ(wordsOf(lines), expected); + ASSERT_FALSE(lines.empty()); + EXPECT_EQ(lines[0].xpos, (std::vector{0, 22, 44})); +} + +TEST(KoreanLineBreaking, HangulGluedAcrossInlineStyleStaysTogether) { + Hyphenator::setPreferredLanguage("ko"); + GfxRenderer renderer; + BlockStyle style; + style.alignment = CssTextAlign::Left; + style.textIndentDefined = true; + ParsedText text(false, false, false, false, false, 0, style); + text.addWord("가나", EpdFontFamily::REGULAR); + text.addWord("한국", EpdFontFamily::REGULAR); + text.addWord("어", EpdFontFamily::BOLD, false, /*attachToPrevious=*/true); + std::vector> lines; + ASSERT_TRUE(text.layoutAndExtractLines(renderer, 0, 40, [&](std::shared_ptr block, uint32_t, uint32_t) { + auto& words = lines.emplace_back(); + for (uint16_t i = 0; i < block->wordCount(); ++i) words.emplace_back(block->wordText(i)); + })); + const std::vector> expected{{"가나"}, {"한국", "어"}}; + EXPECT_EQ(lines, expected); +} diff --git a/test/korean_line_breaking/stubs/GfxRenderer.h b/test/korean_line_breaking/stubs/GfxRenderer.h new file mode 100644 index 00000000000..b6447e3b735 --- /dev/null +++ b/test/korean_line_breaking/stubs/GfxRenderer.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +#include +#include +#include + +namespace BidiUtils { +enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 }; +} + +class GfxRenderer { + public: + int getFontAscenderSize(int id) const { return 12 + id; } + int getLineHeight(int id) const { return 16 + id; } + static int characters(const char* text) { + int n = 0; + for (; *text; ++text) + if ((static_cast(*text) & 0xC0) != 0x80) ++n; + return n; + } + int getTextWidth(int, const char* text, EpdFontFamily::Style = EpdFontFamily::REGULAR, + BidiUtils::BidiBaseDir = BidiUtils::BidiBaseDir::AUTO) const { + return characters(text) * 8; + } + int getTextAdvanceX(int id, const char* text, EpdFontFamily::Style style, uint32_t = 0) const { + return getTextWidth(id, text, style); + } + int getSpaceWidth(int, EpdFontFamily::Style) const { return 4; } + int getKerning(int, uint32_t, uint32_t, EpdFontFamily::Style) const { return 0; } + int getSpaceAdvance(int, uint32_t, uint32_t, EpdFontFamily::Style) const { return 4; } + bool isFontCacheScanning() const { return false; } + template + void drawLine(Args...) const {} + template + void drawText(Args...) const {} + template + void fillRect(Args...) const {} + bool isSdCardFont(int) const { return false; } + bool releaseSdCardFontForLowMemory(int, bool = false) { return false; } + bool ensureSdCardFontReady(int, const uint32_t*, size_t, bool, bool, uint8_t) const { return true; } + bool ensureSdCardFontReady(int, const std::deque&, bool, uint8_t) const { return true; } + bool ensureSdCardFontReady(int, const char*, uint8_t) const { return true; } + std::vector wrappedText(int, const char*, int, int, + EpdFontFamily::Style = EpdFontFamily::REGULAR) const { + return {}; + } +}; diff --git a/test/library_builder/BookCacheUtilsTest.cpp b/test/library_builder/BookCacheUtilsTest.cpp index 3a6212ceb68..51efeb2e796 100644 --- a/test/library_builder/BookCacheUtilsTest.cpp +++ b/test/library_builder/BookCacheUtilsTest.cpp @@ -2,7 +2,9 @@ #include +#include "../../src/activities/reader/BookStatsTracking.h" #include "../../src/util/BookCacheUtils.h" +#include "CrossPointSettings.h" #include "Epub.h" #include "HalStorage.h" @@ -14,10 +16,12 @@ class BookCacheUtilsTest : public ::testing::Test { void SetUp() override { fake::reset(); + SETTINGS.trackReadingStats = 1; 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 + "/reading_stats_off", "off"); fake::add(cachePath + "/book.bin", "derived"); } @@ -30,6 +34,7 @@ class BookCacheUtilsTest : public ::testing::Test { expectFile(cachePath + "/progress.bin", "progress"); expectFile(cachePath + "/stats_v5.bin", "stats"); expectFile(cachePath + "/reader_settings.bin", "settings"); + expectFile(cachePath + "/reading_stats_off", "off"); } }; @@ -62,4 +67,33 @@ TEST_F(BookCacheUtilsTest, RetryRecoversProgressAfterFailedRestore) { EXPECT_FALSE(Storage.exists((cachePath + ".upload_preserve_progress.bin").c_str())); } +TEST_F(BookCacheUtilsTest, BookTrackingDefaultsOnAndGlobalOffTakesPrecedence) { + fake::files.erase(cachePath + "/reading_stats_off"); + EXPECT_TRUE(BookStatsTracking::isEnabled(cachePath)); + SETTINGS.trackReadingStats = 0; + EXPECT_FALSE(BookStatsTracking::isEnabled(cachePath)); + SETTINGS.trackReadingStats = 1; + EXPECT_TRUE(BookStatsTracking::isEnabled(cachePath)); +} + +TEST_F(BookCacheUtilsTest, BookChoiceSurvivesCacheClearAndStatsDeletion) { + ASSERT_FALSE(BookStatsTracking::isBookEnabled(cachePath)); + ASSERT_TRUE(clearBookCachePreservingUserState("/a.epub")); + ASSERT_FALSE(BookStatsTracking::isBookEnabled(cachePath)); + ASSERT_TRUE(Storage.remove((cachePath + "/stats_v5.bin").c_str())); + EXPECT_FALSE(BookStatsTracking::isBookEnabled(cachePath)); + ASSERT_TRUE(BookStatsTracking::setBookEnabled(cachePath, true)); + EXPECT_TRUE(BookStatsTracking::isEnabled(cachePath)); +} + +TEST_F(BookCacheUtilsTest, FailedBookToggleKeepsPreviousChoice) { + ASSERT_TRUE(BookStatsTracking::setBookEnabled(cachePath, true)); + fake::failRenameToPath = cachePath + "/reading_stats_off"; + EXPECT_FALSE(BookStatsTracking::setBookEnabled(cachePath, false)); + EXPECT_TRUE(BookStatsTracking::isBookEnabled(cachePath)); + EXPECT_FALSE(Storage.exists((cachePath + "/reading_stats_off.tmp").c_str())); + ASSERT_TRUE(BookStatsTracking::setBookEnabled(cachePath, false)); + EXPECT_FALSE(BookStatsTracking::isEnabled(cachePath)); +} + } // namespace diff --git a/test/library_builder/CMakeLists.txt b/test/library_builder/CMakeLists.txt index f30413c65b7..4fc3b3738df 100644 --- a/test/library_builder/CMakeLists.txt +++ b/test/library_builder/CMakeLists.txt @@ -18,6 +18,7 @@ gtest_discover_tests(LibraryBuilderTest) add_executable(BookCacheUtilsTest BookCacheUtilsTest.cpp ${REPO_ROOT}/src/util/BookCacheUtils.cpp + ${REPO_ROOT}/src/activities/reader/BookStatsTracking.cpp ) target_include_directories(BookCacheUtilsTest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/stubs diff --git a/test/library_builder/stubs/CrossPointSettings.h b/test/library_builder/stubs/CrossPointSettings.h new file mode 100644 index 00000000000..b90db044b14 --- /dev/null +++ b/test/library_builder/stubs/CrossPointSettings.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +struct CrossPointSettings { + uint8_t trackReadingStats = 1; + bool shouldTrackReadingStats() const { return trackReadingStats != 0; } +}; + +inline CrossPointSettings SETTINGS; diff --git a/test/library_builder/stubs/HalStorage.h b/test/library_builder/stubs/HalStorage.h index 4b41abc4867..43773526bca 100644 --- a/test/library_builder/stubs/HalStorage.h +++ b/test/library_builder/stubs/HalStorage.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include diff --git a/test/reader_drawer_model/ReaderDrawerModelTest.cpp b/test/reader_drawer_model/ReaderDrawerModelTest.cpp index a09a3a65e36..3366081566e 100644 --- a/test/reader_drawer_model/ReaderDrawerModelTest.cpp +++ b/test/reader_drawer_model/ReaderDrawerModelTest.cpp @@ -152,11 +152,31 @@ TEST(ReaderDrawerModel, CatalogOrderAndConditionalRowsMatchTouchDesign) { EXPECT_EQ(location.items[6], ReaderDrawerCatalogItem::DisplayQr); const auto& settings = complete[static_cast(ReaderDrawerTab::Settings)]; - EXPECT_EQ(settings.count, 10); + EXPECT_EQ(settings.count, 11); EXPECT_EQ(settings.items[0], ReaderDrawerCatalogItem::StatusBar); EXPECT_EQ(settings.items[1], ReaderDrawerCatalogItem::Controls); - EXPECT_EQ(settings.items[6], ReaderDrawerCatalogItem::ResetReadingPace); - EXPECT_EQ(settings.items[9], ReaderDrawerCatalogItem::ResetBookReaderSettings); + EXPECT_EQ(settings.items[6], ReaderDrawerCatalogItem::TrackBookStats); + EXPECT_EQ(settings.items[7], ReaderDrawerCatalogItem::ResetReadingPace); + EXPECT_EQ(settings.items[10], ReaderDrawerCatalogItem::ResetBookReaderSettings); +} + +TEST(ReaderDrawerModel, StatsRowsFollowGlobalAndBookTrackingChoices) { + ReaderDrawerAvailability available{}; + available.buttonDevice = true; + available.bookStatsEnabled = false; + auto catalog = makeReaderDrawerCatalog(available); + EXPECT_EQ(catalog[static_cast(ReaderDrawerTab::More)].count, 3); + const auto& settings = catalog[static_cast(ReaderDrawerTab::Settings)]; + EXPECT_EQ(settings.items[6], ReaderDrawerCatalogItem::TrackBookStats); + EXPECT_EQ(settings.items[settings.count - 1], ReaderDrawerCatalogItem::ResetBookReaderSettings); + + available.globalStatsEnabled = false; + catalog = makeReaderDrawerCatalog(available); + const auto& globalOffSettings = catalog[static_cast(ReaderDrawerTab::Settings)]; + for (uint8_t i = 0; i < globalOffSettings.count; ++i) { + EXPECT_NE(globalOffSettings.items[i], ReaderDrawerCatalogItem::TrackBookStats); + EXPECT_NE(globalOffSettings.items[i], ReaderDrawerCatalogItem::DeleteStats); + } } TEST(ReaderDrawerModel, ButtonDevicesRestoreStatsAndTransfersInTheirTabs) { diff --git a/test/reader_status_bar_config/ReaderStatusBarConfigTest.cpp b/test/reader_status_bar_config/ReaderStatusBarConfigTest.cpp index cf5ad9f4585..074d479a9f5 100644 --- a/test/reader_status_bar_config/ReaderStatusBarConfigTest.cpp +++ b/test/reader_status_bar_config/ReaderStatusBarConfigTest.cpp @@ -174,10 +174,86 @@ TEST(ReaderStatusBarConfig, RepairsDamagedSavedValuesWithoutDroppingValidSlots) EXPECT_EQ(repaired.progressBarThickness, 1); } -TEST(ReaderStatusBarConfig, WrongLengthSavedBarKeepsCurrentDefault) { +TEST(ReaderStatusBarConfig, MigratesSavedSixSlotBarsAndResavesSevenSlots) { + JsonDocument saved; + JsonArray topSlots = saved["top"]["slots"].to(); + for (const auto item : {ReaderStatusBarItem::Clock, ReaderStatusBarItem::Battery, ReaderStatusBarItem::TitleBook, + ReaderStatusBarItem::StablePageNumber, ReaderStatusBarItem::TimeLeftChapter, + ReaderStatusBarItem::BookProgressPercentage}) { + topSlots.add(static_cast(item)); + } + saved["top"]["percentageFormat"] = 2; + saved["top"]["progressBar"] = 0; + saved["top"]["thickness"] = 2; + + JsonArray bottomSlots = saved["bottom"]["slots"].to(); + for (const auto item : {ReaderStatusBarItem::Battery, ReaderStatusBarItem::TimeLeftBook, + ReaderStatusBarItem::TitleChapter, ReaderStatusBarItem::ChapterPageCount, + ReaderStatusBarItem::StablePageNumber, ReaderStatusBarItem::BookProgressPercentage}) { + bottomSlots.add(static_cast(item)); + } + saved["bottom"]["percentageFormat"] = 1; + saved["bottom"]["progressBar"] = 1; + saved["bottom"]["thickness"] = 0; + + ReaderStatusBarConfig top; + ReaderStatusBarConfig bottom; + EXPECT_TRUE(repairReaderStatusBarJson(saved["top"], top, true, 3, 3, 3)); + EXPECT_TRUE(repairReaderStatusBarJson(saved["bottom"], bottom, true, 3, 3, 3)); + const std::array expectedTop{ReaderStatusBarItem::Clock, + ReaderStatusBarItem::Battery, + ReaderStatusBarItem::Empty, + ReaderStatusBarItem::TitleBook, + ReaderStatusBarItem::StablePageNumber, + ReaderStatusBarItem::TimeLeftChapter, + ReaderStatusBarItem::BookProgressPercentage}; + const std::array expectedBottom{ReaderStatusBarItem::Battery, + ReaderStatusBarItem::TimeLeftBook, + ReaderStatusBarItem::Empty, + ReaderStatusBarItem::TitleChapter, + ReaderStatusBarItem::ChapterPageCount, + ReaderStatusBarItem::StablePageNumber, + ReaderStatusBarItem::BookProgressPercentage}; + EXPECT_EQ(top.slots, expectedTop); + EXPECT_EQ(bottom.slots, expectedBottom); + EXPECT_EQ(top.percentageFormat, 2); + EXPECT_EQ(top.progressBar, 0); + EXPECT_EQ(top.progressBarThickness, 2); + EXPECT_EQ(bottom.percentageFormat, 1); + EXPECT_EQ(bottom.progressBar, 1); + EXPECT_EQ(bottom.progressBarThickness, 0); + + JsonDocument resaved; + writeReaderStatusBarJson(resaved["top"].to(), top); + writeReaderStatusBarJson(resaved["bottom"].to(), bottom); + EXPECT_EQ(resaved["top"]["slots"].as().size(), ReaderStatusBarConfig::SLOT_COUNT); + EXPECT_EQ(resaved["bottom"]["slots"].as().size(), ReaderStatusBarConfig::SLOT_COUNT); + EXPECT_FALSE(repairReaderStatusBarJson(resaved["top"], top, true, 3, 3, 3)); + EXPECT_FALSE(repairReaderStatusBarJson(resaved["bottom"], bottom, true, 3, 3, 3)); +} + +TEST(ReaderStatusBarConfig, MigratesValidSixSlotItemsWhenAnotherItemIsInvalid) { + JsonDocument saved; + JsonArray slots = saved["slots"].to(); + for (const int item : {2, 1, 9, 5, 99, 7}) slots.add(item); + saved["progressBar"] = 0; + + ReaderStatusBarConfig config; + EXPECT_TRUE(repairReaderStatusBarJson(saved.as(), config, false, 3, 3, 3)); + EXPECT_EQ(config.slots[0], ReaderStatusBarItem::Battery); + EXPECT_EQ(config.slots[1], ReaderStatusBarItem::Empty); + EXPECT_EQ(config.slots[2], ReaderStatusBarItem::Empty); + EXPECT_EQ(config.slots[3], ReaderStatusBarItem::TitleChapter); + EXPECT_EQ(config.slots[4], ReaderStatusBarItem::ChapterPageCount); + EXPECT_EQ(config.slots[5], ReaderStatusBarItem::Empty); + EXPECT_EQ(config.slots[6], ReaderStatusBarItem::BookProgressPercentage); + EXPECT_EQ(config.progressBar, 0); +} + +TEST(ReaderStatusBarConfig, UnknownLengthSavedBarKeepsCurrentDefault) { JsonDocument saved; JsonArray slots = saved["slots"].to(); - for (int i = 0; i < 6; ++i) slots.add(static_cast(ReaderStatusBarItem::Battery)); + for (int i = 0; i < 5; ++i) slots.add(static_cast(ReaderStatusBarItem::Battery)); ReaderStatusBarConfig config; config.slots[ReaderStatusBarConfig::CENTER] = ReaderStatusBarItem::TitleChapter; diff --git a/test/section_persistence/SectionPersistenceTest.cpp b/test/section_persistence/SectionPersistenceTest.cpp index 6fa8b5b89cc..eaa1cf885c0 100644 --- a/test/section_persistence/SectionPersistenceTest.cpp +++ b/test/section_persistence/SectionPersistenceTest.cpp @@ -19,10 +19,10 @@ #include namespace { -constexpr uint8_t kFullVersion = 78; -constexpr uint8_t kPartialVersion = 0xF2; -constexpr uint8_t kPreviousFullVersion = 77; -constexpr uint8_t kPreviousPartialVersion = 0xF3; +constexpr uint8_t kFullVersion = 79; +constexpr uint8_t kPartialVersion = 0xF4; +constexpr uint8_t kPreviousFullVersion = 78; +constexpr uint8_t kPreviousPartialVersion = 0xF2; ReaderRenderSpec renderSpec() { ReaderRenderSpec spec; diff --git a/web/pages/settings.js b/web/pages/settings.js index a9d0f6289fc..6af237aea7f 100644 --- a/web/pages/settings.js +++ b/web/pages/settings.js @@ -5,8 +5,6 @@ let allSettings = []; const SLEEP_SCREEN_MODE = { QUICK_RESUME: 6 }; - const POWER_BUTTON_FOOTNOTES_DISPLAY_INDEX = 15; - const LONG_PRESS_MENU_FOOTNOTES_DISPLAY_INDEX = 14; function escapeHtml(unsafe) { return unsafe @@ -170,14 +168,14 @@ let allSettings = []; } function updateSettingsVisibility() { - const shortPwrBtnVal = getControlValue('shortPwrBtn'); - const longPwrBtnVal = getControlValue('longPwrBtn'); - const longPressMenuActionVal = getControlValue('longPressMenuAction'); const pwrBtnFootnoteBackRow = document.getElementById('row-setting-pwrBtnFootnoteBack'); if (pwrBtnFootnoteBackRow) { - if (shortPwrBtnVal === POWER_BUTTON_FOOTNOTES_DISPLAY_INDEX || - longPwrBtnVal === POWER_BUTTON_FOOTNOTES_DISPLAY_INDEX || - longPressMenuActionVal === LONG_PRESS_MENU_FOOTNOTES_DISPLAY_INDEX) { + const footnoteActionSelected = ['shortPwrBtn', 'longPwrBtn', 'longPressMenuAction', 'longPressBackAction'] + .some(key => { + const setting = allSettings.find(item => item.key === key); + return setting && getControlValue(key) === setting.footnotesIndex; + }); + if (footnoteActionSelected) { pwrBtnFootnoteBackRow.style.display = ''; } else { pwrBtnFootnoteBackRow.style.display = 'none'; @@ -187,7 +185,8 @@ let allSettings = []; function handleSettingChanged(key) { syncQuickResumeTimeoutForSleepScreen(key === 'sleepScreen', key === 'quickResumeSleepScreen'); - if (key === 'shortPwrBtn' || key === 'longPwrBtn' || key === 'longPressMenuAction') { + if (key === 'shortPwrBtn' || key === 'longPwrBtn' || key === 'longPressMenuAction' || + key === 'longPressBackAction') { updateSettingsVisibility(); } markChanged(); @@ -280,6 +279,9 @@ let allSettings = []; for (const key in changes) { originalValues[key] = changes[key]; } + if (Object.prototype.hasOwnProperty.call(changes, 'trackReadingStats')) { + await loadSettings(); + } showMessage('Settings saved successfully!', false); } catch (e) {