diff --git a/CHANGELOG.md b/CHANGELOG.md index 6383f424529..7e43ecf114e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## [Unreleased] +## [v1.6.1] - 2026-09-30 ### Added @@ -26,6 +26,7 @@ - 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. +- Show small EPUB images within the surrounding text line instead of separating them from the paragraph. ### Changed @@ -54,6 +55,8 @@ - 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. +- Menu buttons now follow the device layout: Left/Right switch tabs and Up/Down select rows in reader menus and global settings. +- The KOReader Sync progress choice now shows remote and local progress as clear cards with large percentages, progress bars, and an "Ahead" marker on the further position. ### Fixed @@ -85,6 +88,7 @@ - Dragging or scrolling on a list or menu no longer highlights or selects the row under your finger, and a short drag no longer opens it. Rows highlight once your finger rests on them briefly, so long-press still works. - Turning from an EPUB image page whose grayscale pass finished to another image page now runs a cleanup refresh, so the previous image no longer ghosts on the X4 Pro. Image pages skipped before their grayscale pass finishes no longer force a flash on the next page. - Keyboard rows are shorter on button-only devices so side-button hints no longer cover the keys. +- EPUB paragraphs now inherit first-line indentation from HTML and body styles while retaining paragraph-level overrides. - Saved clipping lists now show a scrollbar when more clippings are available below the visible rows. - EPUB Safe Mode no longer pins inherited fonts and page layout as personal book settings. - The X4 Pro Home button now steps back through dictionary lookup, chapter selection, and nested settings instead of jumping to Home. @@ -121,6 +125,14 @@ - 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. - KOReader authentication now rejects unexpectedly large server responses to avoid crashes. +- EPUBs rewritten with alternate XML namespace prefixes now open normally instead of jumping straight to End of Book. +- Carousel Home screen reuses cached cover artwork after reading and prepares other positions only when viewed, while keeping progress, reading time, and menu choices current. +- Sleep entry skips unnecessary pauses and repeated session-state writes. PSRAM devices show independent sleep screens before saving reader progress, and Direct grayscale images skip an unused decode. +- Renaming books in the web portal preserves reading progress, stats, bookmarks, clippings, reader settings, and cached content. +- Side-button Orientation Aware, optional keyboard layouts, and the XTC reader's touchscreen and reading stats settings now show toggle switches instead of Yes/No or On/Off labels. +- In-book setting choices now open with the current value highlighted and visible, including fonts and font sizes. +- Changing the Home theme or UI scale through the frontlight drawer no longer leaves overlapping covers or stale controls. +- Fix filename-based KOReader sync failing at split-chapter boundaries in optimized EPUBs that already contain sync mapping data. ### Security diff --git a/docs/controls.md b/docs/controls.md index dd7c76dbcf2..755d7f0908c 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -28,9 +28,10 @@ Note: Even though some actions assigned to the front buttons could be used globa ### Side Buttons -- Layout - Orientation aware -- Long-press action +- Side-button chord (where supported) +- Left/Up short-press and long-press actions +- Right/Down short-press and long-press actions ### Taps & Gestures @@ -40,13 +41,16 @@ Note: Even though some actions assigned to the front buttons could be used globa - Two-finger Screen Rotation (on supported multi-touch devices) - Tap to Hide Status Bar - Two-finger Swipe (on supported multi-touch devices) +- Edge Gestures ### Next Page and Previous Page Gestures On touchscreen devices, **Next Page** and **Previous Page** are in **Settings > Controls > Taps & Gestures**. They change page-turn gestures while -reading and can be configured independently. **Next Page** controls taps and -left swipes; **Previous Page** controls taps and right swipes. +reading and can be configured independently. In left-to-right books, **Next +Page** controls taps and left swipes; **Previous Page** controls taps and right +swipes. Right-to-left EPUBs reverse the normal tap zones and horizontal swipe +directions to follow reading order. Both settings offer the same options: @@ -73,7 +77,7 @@ On touchscreen devices with multi-touch support, enable **Pinch to Resize Font** in **Settings > Controls > Taps & Gestures**. While reading an EPUB or TXT book, move two fingers apart to increase the font or together to decrease it. Each completed pinch changes one available font-size step. Pinch resizing also -requires **Touch Reader Controls** to be enabled. XTC pages are pre-rendered, +requires reader touch input to be enabled. XTC pages are pre-rendered, so this option cannot resize them. ## Two-finger Screen Rotation @@ -91,9 +95,9 @@ read; the four-way **Reading Orientation** setting still works. **Tap to Hide Status Bar** is enabled by default. When it is enabled, tap the visible status-bar area while reading to show or hide the entire bar for the current reading session. The toggle does not change page layout or page breaks; -tap the same status-bar region again to restore a hidden bar. Use **Customize -Status Bar** to choose which items the bar contains. The tap is available while -**Touch Reader Controls** is enabled. For XTC books, when an XTC status bar is +tap the same status-bar region again to restore a hidden bar. Use **Status +Bars** to choose which items the top and bottom bars contain. The tap is available while reader touch input +is enabled. For XTC books, when an XTC status bar is enabled, tap its configured top or bottom edge both to hide and to restore it. ## Two-finger Swipe Actions @@ -116,37 +120,64 @@ Available actions depend on the device and reader: | Next Chapter / Previous Chapter | EPUB readers | | Increase Font Size / Decrease Font Size | EPUB and TXT readers | -Use a clear, mostly straight motion so both contacts are recognized as one -gesture. Configured two-finger swipes are handled separately from the ordinary -one-finger page-turn mapping. On image-based XTC books, chapter and font-size +Brightness and warmth respond while you drag, with longer swipes allowing +finer one-point adjustments. Use a clear, mostly straight motion so both +contacts are recognized as one gesture. Configured two-finger swipes are +handled separately from the ordinary one-finger page-turn mapping. On image-based XTC books, chapter and font-size actions are consumed but cannot change the pre-rendered pages. -## Side Button Long-press Action +## Edge Gestures + +On touchscreen devices, open **Settings > Controls > Taps & Gestures > Edge +Gestures**. Assign actions separately to **Left Edge Up**, **Left Edge Down**, +**Right Edge Up**, and **Right Edge Down**. Start with one finger close to the +chosen screen edge and slide vertically while staying along that edge. All four +start as **Not Set**. + +The available actions match [Two-finger Swipe Actions](#two-finger-swipe-actions): +brightness and warmth on supported hardware, chapter changes in EPUBs, and +font-size changes in EPUB/TXT books. Edge slides do not require multi-touch. +Brightness and warmth respond while you drag; a longer slide gives finer +one-point adjustments. Chapter and font-size actions run when you lift your +finger. XTC pages cannot be resized or use the EPUB chapter actions. + +## Side Button Actions -When set to `Change Font Size`, hold a side button for about 2 seconds: +Open **Settings > Controls > Side Buttons** to assign a **Short-press Action** +and **Long-press Action** independently to **Left/Up** and **Right/Down**. +These actions apply while reading. A short action runs on release; a configured +long action runs after a hold of about **0.7 seconds**, without also running +the short action. Existing side-button layouts are converted to matching +individual actions when you update. -- Up increases font size -- Down decreases font size +Alongside the usual reader shortcuts, you can choose: -When set to `Orientation Change`, hold a side button for about 2 seconds: +- **Previous Page** or **Next Page** to swap page-turn directions or make both + buttons advance. +- **Previous Chapter** or **Next Chapter** for EPUB chapter navigation. +- **Increase Font Size** or **Decrease Font Size** for EPUB/TXT text. +- **Rotate Page CCW**, **Rotate Page CW**, or **Rotate Page 180°** for EPUB/TXT + orientation changes. The labels describe the direction the page turns. +- **Ignore** to leave a press unassigned. -- Up cycles through the orientations in the following order: `Landscape CCW` -> `Inverted` -> `Landscape CW` -> `Portrait` -- Down cycles through the orientations in the following order: `Landscape CW` -> `Inverted` -> `Landscape CCW` -> `Portrait` +XTC books have pre-rendered pages, so font-size and page-rotation actions do not +apply. **Orientation aware** remains a separate option for side-button mapping. ## Power, Back, and Menu Button Actions Defaults: - Short-press Power Button Action: Ignore -- Long-press Power Button Action: Sleep +- Long-press Power Button Action: Sleep/Wake - Long-press Back Button Action: Browse Files - Long Press Menu Button Action: Ignore Available actions include: - Ignore -- Sleep -- Page Turn +- Sleep (also available to chord and Home-button shortcuts) +- Sleep/Wake or Wake (Power button only) +- Next Page - Refresh Screen - Change Font - Guide Dots @@ -165,6 +196,7 @@ Available actions include: - Footnotes - Dark Mode - Browse Files +- Library - Create Clipping - Look Up Word - Quick Lock @@ -172,10 +204,20 @@ Available actions include: - Toggle Frontlight (on supported devices) - Toggle Touchscreen (on supported devices) +Power short- and long-press actions are separate. A short press wakes the +device only when its **Short-press Action** is **Sleep/Wake** or **Wake**. +**Sleep** only puts an awake device to sleep; **Wake** does nothing while it is +awake. Holding Power always wakes the sleeping device. For the full startup +instructions, see [Power On / Off](./user-guide.md#power-on--off). + +Assign **Library** to a Power, Back/Menu long-press, chord, Home-button, or +Quick Actions shortcut to open the book list directly, where that shortcut is +supported. + ## Power + Up Shortcut The **Power + Up** shortcut runs the action selected in **Settings > Controls > -Power + Up**. It is disabled by default. Press the **Power** and **Volume Up** +Power Button > Power + Up**. It is disabled by default. Press the **Power** and **Volume Up** buttons together to trigger it, then release both buttons before using another shortcut. @@ -210,6 +252,12 @@ pause while Quick Lock is active. ## Footnote Shortcut -When a shortcut is mapped to Footnotes, the shortcut opens the footnotes submenu while reading. If the current page has only one footnote, CrossInk opens that referenced page directly. +When a shortcut is mapped to **Footnotes**, it opens the page-reference selector +(or a list fallback) while reading. If the current page has only one footnote, +CrossInk opens that referenced page directly. -The **Quick-return from Footnotes** setting controls whether the Power button acts like Back after opening a footnote page, making it faster to return to the original reading position. +The **Quick-return from Footnotes** setting controls whether the Power button +acts like Back after opening a footnote page. It appears under **Settings > +Controls > Power Button** only after assigning **Footnotes** to Power short/long +press or Back/Menu long press. See [Footnote Navigation](./user-guide.md#footnote-navigation) +for selecting references on the page. diff --git a/docs/data-cache.md b/docs/data-cache.md index a7dc1f33a28..a84d680bc27 100644 --- a/docs/data-cache.md +++ b/docs/data-cache.md @@ -29,7 +29,7 @@ The main data directory is `.crosspoint` on the SD card. It stores render caches ├── koreader.json # KOReader sync credentials ├── bookmarks/ # Bookmark files, one per book ├── clippings/ # EPUB clipping/highlight files, one per book -├── home_carousel_cache.bin # Lyra Carousel home-screen snapshot cache +├── home_carousel_cache_.bin # Lyra Carousel artwork cache for each book position ├── sleep_frame.bin # Temporary sleep overlay framebuffer, when used ├── epub_12471232/ # Each EPUB is cached to epub_ │ ├── progress.bin # Reading position slot A (chapter, page, etc.) @@ -66,7 +66,7 @@ To clear EPUB/XTC render caches from the device UI without deleting settings or Cache folders are path-based. Moving a book file can create a new cache directory, so the moved copy may start with fresh reading progress unless the firmware migrates the cache for that move. CrossInk migrates cache and bookmark data for the built-in move-to-Read flow and related file-browser move actions. -EPUB reader font, page layout, styling, and reading-aid settings normally come from the global Reader settings. Changes made inside an EPUB override only the fields whose values differ from the global defaults; the other fields continue to inherit later global changes. EPUB render mode is stored separately per book so a problematic title can be switched to Balanced or Light rendering from the File Browser or Recent Books long-press menus before opening it. Older full-snapshot book overrides retain their original behavior until reset or edited again. +EPUB reader font, page layout, styling, and reading-aid settings normally come from the global Reader settings. Changes made inside an EPUB override only the fields whose values differ from the global defaults; the other fields continue to inherit later global changes. EPUB render mode is stored separately per book so a problematic title can be switched to Balanced or Light rendering from the File Browser or Library long-press menus before opening it. Older full-snapshot book overrides retain their original behavior until reset or edited again. EPUB clippings and highlights live outside the EPUB render-cache folder in `/.crosspoint/clippings/`. Each book gets a binary clipping file named from the @@ -91,13 +91,15 @@ For binary file layout details, see [File Formats](./file-formats.md). Library replaces the Recent Books screen. -The Library reconciles its index with the SD card on the first visit after boot, +The Library reconciles its index with the SD card on the first visit after a cold boot, after file changes in File Browser, and after leaving a download or file-transfer -screen. Ordinary return visits reuse the index. Failed or memory-limited scans are -retried on the next visit. The Library's refresh action always scans again; use it -if files were changed externally while the firmware stayed running. +screen. Ordinary return visits and deep-sleep wakes reuse a successfully scanned +index. Failed or memory-limited scans are retried on the next visit. The Library's +refresh action always scans again; use it +if files were changed externally while the firmware stayed running or the reader +was asleep. Unchanged books reuse their cached metadata. -**Settings > Display > Use Book Metadata** selects embedded EPUB titles and +**Library > Settings > Use Book Metadata** selects embedded EPUB titles and authors; disabling it uses filenames. TXT, Markdown and XTC files use filename fallbacks. CLX1 version 2 adds a first-name author permutation; older Library indexes rebuild automatically when Library opens. diff --git a/docs/dictionary.md b/docs/dictionary.md index 2d8ca290b99..04697da1e74 100644 --- a/docs/dictionary.md +++ b/docs/dictionary.md @@ -32,7 +32,7 @@ Example folder structure: `SDCARD/.dictionaries/Cambridge/*.dict`. Make sure the - `/dictionaries/` 2. If no dictionary has been selected before, the first dictionary in the alphabetical device list is selected automatically. 3. To choose between multiple dictionaries, open **Settings -> Reader -> Dictionary** on the device and select one from the list. -4. Per book dictionaries can be set from within the in-reader menu `Book Options -> Settings Gear tab -> Book Dictionary` +4. Per book dictionaries can be set from within the in-reader menu **Reader Menu > Settings (gear) > Book Dictionary** ### Preparing Compressed or Large Dictionaries @@ -175,7 +175,8 @@ The history screen shows the 50 most recent entries. The on-disk history is appe ## IPA Phonetic Characters -Dictionary definitions use the active reader font and size by default. If at least one dictionary is installed, you can set the default dictionary font and size in **Settings > Reader > Font Options**. Books inherit those defaults unless you choose a different font or size in **Book Options > Font Options**. Choose **Use Global** in Book Options to return a book to the global defaults. When the reader uses an SD-card font, you can choose a different dictionary size while keeping that same family; built-in reader fonts continue to use their active size. A saved size with no matching file uses the closest available size from the dictionary family. +Dictionary definitions use the active reader font and size by default. If at least one dictionary is installed, you can set the default dictionary font and size in **Settings > Reader > Font Options**. Books inherit those defaults unless you choose a different font or size in **Reader Menu > Font > Dictionary Font**. Choose **Use Global** there to return a book to the global dictionary font +and size defaults. When the reader uses an SD-card font, you can choose a different dictionary size while keeping that same family; built-in reader fonts continue to use their active size. A saved size with no matching file uses the closest available size from the dictionary family. Only one SD-card font family is loaded at a time: CrossInk temporarily swaps to the dictionary font while a definition is open, then restores the reader font when you close it. If a book's selected dictionary font is missing, the definition temporarily uses the global dictionary font. If that is also unavailable, it falls back to the reader font. The per-book selection is kept so it resumes automatically if you reinstall the family. diff --git a/docs/epub-indexing.md b/docs/epub-indexing.md index 5e5a8c3cfdd..6675557790c 100644 --- a/docs/epub-indexing.md +++ b/docs/epub-indexing.md @@ -90,7 +90,7 @@ To change the default for future EPUBs, open **Settings → Reader → Indexing Method** and choose **Incremental**, **Full Section**, or **IncreMENTAL**. To change it only for the EPUB you are reading, open the reader menu, choose -**Reader Options**, then choose **Indexing Method**. The per-book choice is +the **Settings** tab, then choose **Indexing Method**. The per-book choice is saved with that book and overrides the global default without changing your other books. diff --git a/docs/epub-render-modes.md b/docs/epub-render-modes.md index 0778f3dc44c..f263e306760 100644 --- a/docs/epub-render-modes.md +++ b/docs/epub-render-modes.md @@ -131,7 +131,7 @@ Mode** or **Light Mode** over the current page. The message disappears automatically and does not change page layout, margins, or reading position. If Safe Mode also cannot open the book, long-press the book in **File Browser** -or **Recent Books** and choose **Reset Book Reader Settings**. This clears only +or **Library** and choose **Reset Book Reader Settings**. This clears only that book's reader settings, including a saved Safe Mode or render-mode override. It preserves reading progress, bookmarks, clippings, and reading stats. @@ -143,13 +143,13 @@ CrossInk already falls back automatically, but it is useful when you know a book is difficult and want it to start in a specific mode: - Long-press an EPUB in **File Browser** -- Long-press an EPUB in **Recent Books** +- Long-press an EPUB in **Library** - Choose **EPUB Render Mode** You can also change it while reading: 1. Open the reader menu. -2. Choose **Book Options**. +2. Choose the **Settings** tab. 3. Choose **EPUB Render Mode**. Changing render mode rebuilds the affected book layout cache. Your reading diff --git a/docs/file-formats.md b/docs/file-formats.md index 676f8c24127..85ee9821135 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -5,6 +5,27 @@ All POD fields are written in the ESP32 little-endian representation used by `Serialization.h`; strings are length-prefixed UTF-8 unless a format notes a fixed-size char buffer. +## `/.crosspoint/home_carousel_cache_.bin` + +### Version 6 + +The v1.6.1 release normalizes development version 8 to version 6, one step +after v1.6.0. The new per-position filenames and artwork cache keys prevent +reuse of older combined snapshots. + +Each Carousel position has a disposable snapshot containing only cover artwork, +titles and position dots. Progress, reading time, header, menu icons and button +hints are drawn live after restoration. Each file contains a `CarouselCacheHeader` +followed by one full framebuffer. The header's `frameCount` records the number of +recent books used to compose the artwork, rather than the number of stored frames. +The key tracks ordered book paths, titles, cover paths, thumbnail availability and +image polarity, rather than reading progress or statistics. + +Frames are rendered and saved only when viewed; returning Home does not prepare +other positions in advance. The development version 7 combined +`home_carousel_cache.bin` is removed after the first successful write. Cache +regeneration is automatic; EPUB layout caches and reading history are unaffected. + ## `/.crosspoint/ttf-rendering.json` This user-owned JSON file stores only custom TTF families whose raster settings @@ -194,6 +215,8 @@ and genre. Version 6 appends the four-byte series position. `book.bin` stores EPUB metadata plus lookup tables for spine and TOC entries. The current firmware writes this version from `BookMetadataCache`. +Version 10 rebuilds metadata with namespace-aware OPF parsing so optimizer-generated +XML prefixes do not leave an empty chapter list. The binary layout is unchanged. Version 9 stores book and TOC title strings NFC-composed so decomposed diacritics render correctly with device fonts. It also rebuilds metadata after the EPUB guide start-reference handling changed. @@ -205,7 +228,7 @@ import std.mem; import std.string; import std.core; -#define EXPECTED_VERSION 9 +#define EXPECTED_VERSION 10 #define MAX_STRING_LENGTH 65535 struct String { @@ -277,7 +300,7 @@ if (parsedSize != fileSize) { ## `reader_settings.bin` -### Version 9 +### Version 10 Each EPUB cache directory may contain `reader_settings.bin`. Missing files mean the book uses global Reader settings and the default auto-page-turn interval. @@ -482,6 +505,27 @@ Binary layout: ## `section.bin` +### Version 81 + +Version 81 carries `text-indent` from the HTML and body root styles into +descendant paragraph blocks. Existing full section caches (byte `80`) and +suspended partial caches (`0xC1`) rebuild so inherited paragraph indentation is +reflected in saved page positions. Complete files use byte `81`; suspended +partials use marker `0xC2`. The CSS rule cache format is unchanged. + +### Version 80 + +Version 80 places small inline images within text lines while keeping larger +images as centered blocks. Page-image records add a one-byte inline flag after +their coordinates; full section caches (byte `80`) and suspended partial caches (`0xC1`) rebuild so +existing books receive the new layout. The CSS rule cache moves to version `19` +so `display: inline` rules retain their meaning. + +The v1.6.1 release retains version `80` and partial marker `0xC1`: development +versions `78` and `79` used the older image payload, and earlier release +preparation used partial marker `0x80`. Reusing those identifiers could accept +incompatible saved pages. All of those older caches rebuild automatically. + ### Version 79 Version 79 keeps the version 78 serialized layout. Korean words now wrap at @@ -522,8 +566,10 @@ version byte `66`, and suspended partials use sentinel byte `0xF6`. The stable v1.5.1 release retains these identifiers from RC6. Do not normalize published RC versions to the previous stable version plus one: v1.5.0 used `60` / `0xF9`, and RC4 already shipped `61` / `0xF8` with older layout output. -Reusing those identifiers could accept stale RC caches as current. Per-book -reader settings likewise retain version `9` and their version 7/8 migrations. +Reusing those identifiers could accept stale RC caches as current. Version 9 +per-book reader settings and their version 7/8 migrations remain +readable. Version 10 adds a field-override mask so a book can inherit unrelated +global reader settings. ### Version 62 diff --git a/docs/nearby-file-transfer.md b/docs/nearby-file-transfer.md index e44793702db..67da6f01275 100644 --- a/docs/nearby-file-transfer.md +++ b/docs/nearby-file-transfer.md @@ -35,7 +35,7 @@ creates a separate copy with a numbered suffix. 1. On the receiving reader, start **Receive File** as described above. 2. On the sending reader, find a supported file in **Browse Files** or - **Recent Books**. + **Library**. 3. Open the file action menu and select **Send to Nearby Device**. 4. Wait for the receiving reader to appear, select it, and wait for approval. 5. Keep both readers on their transfer screens until the sender reports diff --git a/docs/reader-features.md b/docs/reader-features.md index 61ab2cee24f..c391fafc00b 100644 --- a/docs/reader-features.md +++ b/docs/reader-features.md @@ -13,7 +13,8 @@ The sections here focus on larger CrossInk-specific reader features. Small fixes Reader settings are available directly from the in-book menu without leaving the book. -Open the reader menu and select **Book Options** to adjust settings such as: +EPUB books use five icon tabs on every device. Use **Font** and **Layout** +to adjust settings such as: - Font family - Font size @@ -23,11 +24,12 @@ Open the reader menu and select **Book Options** to adjust settings such as: - Alignment - Image rendering - [Publisher Page Numbers](#publisher-page-numbers) -- [Stable Page Numbers](#stable-page-numbers), when the book includes CrossInk reference metadata - [Focus Reading](#focus-reading) / Guide Dots -- Dark Reader Mode -Changes take effect immediately. +Changes take effect immediately, with live previews for font, spacing, and +margin choices. The **More**, **Bookmarks**, and **Settings** tabs contain +navigation, reading tools, and book settings. See the [Reader Menu](./user-guide.md#5-reader-menu) +for the tab map and button controls. EPUB layout choices such as font size and margins can be saved for that book and take precedence over matching global defaults. Status bar settings are global. See [Global settings and individual @@ -100,11 +102,20 @@ styles will fall back to another face. Variable fonts are not supported. See [Scalable Fonts](./scalable-fonts.md) for installation details and limits. -## Dark Reader Mode +## Reader Status Bars -Dark Reader Mode reverses the reader colors so text is shown light-on-dark. +Customize top and bottom status bars separately from **Settings > Reader > +Status Bars**, or from the EPUB Reader Menu's **Settings > Status Bars**. +Each bar has three left slots, one center slot, and three right slots. Choose +items such as titles, page counts, progress, battery, or Time Left, and configure +a book/chapter progress bar. The editor previews each bar where it appears +while reading. These settings are global and apply across EPUB, TXT, and XTC +books; the available information varies by format. -Toggle it from **Reader settings**. +## Dark Reader Mode + +Dark Mode reverses the display colors so text is shown light-on-dark in the +reader and menus. Toggle it from **Settings > Display > Dark Mode**. Dark Reader Mode can also be assigned to shortcut actions, so it can be switched without opening the full settings menu. @@ -120,9 +131,8 @@ EPUB readers can choose from five word-spacing levels: **Normal** and levels **1** through **4**. Higher levels add more space between words, which can make text easier to scan without changing the font size or line height. -Open the reader menu, then select **Reader Options > Font Options > Word -Spacing**. The current EPUB is laid out again when you change this setting, so -the number and positions of pages may change. Word Spacing is not available for +Open the reader menu, then select **Font > Line/Word Spacing > Word Spacing**. The current EPUB is laid out again when you change this +setting, so the number and positions of pages may change. Word Spacing is not available for TXT books. ## Publisher Page Numbers @@ -132,8 +142,8 @@ printed page numbers from a physical edition. When the book includes labeled page-break markers, CrossInk displays those labels in the reader margin beside the matching content. -To enable them, open the reader menu and select **Reader Options > Publisher -Page Numbers**. If an EPUB does not contain labeled page-break markers, there +To enable them, open the reader menu and select **Layout > Publisher Page +Numbers**. If an EPUB does not contain labeled page-break markers, there are no publisher page numbers for CrossInk to display. Publisher page markers are preserved by **CrossInk Default** and **Balanced** @@ -149,12 +159,13 @@ consistent when you change fonts, spacing, orientation, or indexing mode. To enable them: -1. Open the reader menu and select **Reader Options**. +1. Open the reader menu and select the **Settings** tab. 2. Select **Status Bars**. -3. Toggle **Stable Page Numbers** on. +3. Select **Top Status Bar** or **Bottom Status Bar**. +4. Choose a left, center, or right slot, then select **Stable Page Numbers**. -The option appears only when the current EPUB contains valid CrossInk reference -metadata. To create that metadata, optimize the EPUB in the CrossInk web +The item shows a page number only when the current EPUB contains valid CrossInk +reference metadata. To create that metadata, optimize the EPUB in the CrossInk web interface or with [Inky](https://inky.crossink.dev) before uploading it to the reader. In the optimizer's settings, the **Characters per Page** controls the reference-page size; the default is 1,500 characters. Lower values create more reference pages, while higher @@ -190,7 +201,7 @@ Auto Page Turn can advance pages on a timer while reading. CrossInk adds a custom interval picker, so the interval is not limited to the built-in presets. The reader can also remember a different Auto Page Turn interval per book. -Open the reader menu and select **Auto Page Turn** to configure it. +Open the reader menu's **More** tab and select **Auto Page Turn** to configure it. ## Time Left @@ -198,7 +209,7 @@ CrossInk can show estimated time left in the current chapter or book. The estimate is based on your recent forward-page reading pace. Non-linear jumps such as chapter skips, bookmark jumps, and footnote navigation are handled separately so they do not immediately distort the normal reading estimate. -Use **Reset Reading Pace** if the estimate was trained by unusual reading behavior and you want it to learn again from fresh page turns. +Use **Reset Reading Pace** in the EPUB Reader Menu's **Settings** tab if the estimate was trained by unusual reading behavior and you want it to learn again from fresh page turns. ## Bookmarks @@ -246,7 +257,10 @@ 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 for EPUB and XTC files. +CrossInk tracks per-book reading stats automatically and aggregates them into +global stats for EPUB and XTC files. With tracking enabled for the device and +book, you can view that book's **Reading Stats** directly from its **Library** +or **Browse Files** action menu without opening it first. ### Turn tracking on or off diff --git a/docs/touch-navigation.md b/docs/touch-navigation.md index cc94fab60db..d09a4cb9ddc 100644 --- a/docs/touch-navigation.md +++ b/docs/touch-navigation.md @@ -36,14 +36,22 @@ a book to start reading it. Tap and hold to open the context menu. Use the back ### Swipe up for the Home menu -Swipe up to reveal shortcuts for Recent Books, OPDS Browser, Bookmarks & +Swipe up to reveal shortcuts for Library, OPDS Browser, Bookmarks & Clippings, Reading Stats, and File Transfer. Tap an item to open it; tap outside the panel to close the menu. ### Swipe left to switch recent books -When at least two books are in Recent Books, swipe left anywhere on a one-cover -Lyra, Dashboard, or Minimal Home screen to switch to the other recent book. +When at least two books are in your recent reading history, swipe left anywhere +on a one-cover Lyra, Dashboard, or Minimal Home screen to switch to the other +recent book. + +## Cover Grid Home + +On PSRAM devices such as Sticky and X4 Pro, choose **Settings > Display > UI +Theme > Cover Grid** for a Home screen with the current book and up to six +additional covers. Tap a cover to open its book. This is a separate Home theme +from Library's **Recently Opened View** grid option. ## Other touch navigation diff --git a/docs/user-guide.md b/docs/user-guide.md index 4b28e91d51a..b715662d67b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -18,7 +18,7 @@ This guide covers day-to-day device use. For focused reference material, see [Re - [3.1 Home Screen](#31-home-screen) - [3.2 Reading Mode](#32-reading-mode) - [3.3 Browse Files Screen](#33-browse-files-screen) - - [3.4 Recent Books Screen](#34-recent-books-screen) + - [3.4 Library Screen](#34-library-screen) - [3.5 File Transfer Screen](#35-file-transfer-screen) - [3.5.1 Calibre Wireless Transfers](#351-calibre-wireless-transfers) - [3.6 Settings](#36-settings) @@ -110,7 +110,7 @@ Upon turning the device on for the first time, you will be placed on the **[Home ### 3.1 Home Screen -The Home screen is the main entry point to the firmware. From here you can navigate to **[Reading Mode](#4-reading-mode)** with the most recently read book, the **[Browse Files](#33-browse-files-screen)** screen, the **[Recent Books](#34-recent-books-screen)** screen, the **[File Transfer](#35-file-transfer-screen)** screen, or **[Settings](#36-settings)**. +The Home screen is the main entry point to the firmware. From here you can navigate to **[Reading Mode](#4-reading-mode)** with the most recently read book, the **[Browse Files](#33-browse-files-screen)** screen, the **[Library](#34-library-screen)** screen, the **[File Transfer](#35-file-transfer-screen)** screen, or **[Settings](#36-settings)**. ### 3.2 Reading Mode @@ -125,9 +125,36 @@ The Browse Files screen acts as a file and folder browser. The full path to the - **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**, **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 - -The Recent Books screen lists the most recently opened books in a chronological view, displaying title and author. +### 3.4 Library Screen + +**Library** replaces Recent Books and lists supported books across the SD card. +The first visit reads the card to build the list; later visits reuse it and +refresh after file changes or transfers. + +- **Find a book:** Use **Search** to filter by title or author. With **Use Book + Metadata** off, books are shown and searched by filename instead. +- **Sort:** Choose **Date Added**, **Title**, **Author (Last Name)**, **Author + (First Name)**, **Recently Opened**, **Series**, or **Genre**. You can reverse + the sort order. **Recently Opened** shows only your saved reading history, + rather than every book on the card. +- **Buttons:** Use **Up/Down** to select a book and **Confirm** to open it. Hold + **Confirm** for book actions. **Left** opens Sort; **Right** opens the menu + containing **Search**, **Settings**, and **Refresh Library**. +- **Touch:** Tap a book to open it, hold it for book actions, and swipe to scroll. + Use the search, settings, refresh, and sort controls at the top. +- **Library Settings:** Open Library's **Settings** to choose embedded EPUB + metadata or filenames, compact or expanded rows, and a list or cover grid + for **Recently Opened View**. You can also hide finished books, show series + and genre, and filter which book formats appear. +- **Book actions:** EPUB and XTC books can offer **Reading Stats** without + opening the book, when tracking is enabled for the device and that book. + The action menu also includes book-specific controls such as **Mark Finished** + and **Track Reading Stats** where supported. + +If files were changed externally while the reader stayed on, use **Refresh +Library** to scan the card again. **Date Added** uses the file's creation time +when available, so a copy tool that preserves old timestamps may affect its +order. ### 3.5 File Transfer Screen @@ -225,10 +252,13 @@ device model and build. - "Lyra Extended" - Lyra, but displays 3 books instead of 1 on the **[Home Screen](#31-home-screen)** - "Lyra Carousel" - A carousel-based Lyra home layout - "RoundedRaff" - A rounded theme with additional visual styling + - "Cover Grid" (PSRAM devices, such as Sticky and X4 Pro) - Shows the current + book and up to six additional book covers; tap a cover to open that book 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. +- Library display and filtering options are now in **Library > Settings**; see + [Library Screen](#34-library-screen). - **Sunlight Fading Fix**: Configure whether to enable a software-fix for the issue where white X4 models may fade when used in direct sunlight: - "OFF" (default) - Disable the fix @@ -258,31 +288,36 @@ device model and build. **Global settings and individual books:** Choices under **Settings > Reader** are the defaults for your EPUB books. Layout choices such as font family, font -size, and margins can be saved for one book from its **Reader Options** menu. -A saved book choice takes precedence over the matching global setting; layout -choices you have not changed for that book still follow the global defaults. +size, and margins can be saved for one book from the in-book **Font** and +**Layout** tabs. A saved book choice takes precedence over the matching global +setting; choices you have not changed for that book still follow the global defaults. For example, a book with its own font size can still pick up a later change to -your global margins. **Dark Reader Mode** and **Stable Page Numbers** remain -global even when changed from inside a book. - -If a global change does not affect one EPUB, find it in **Browse Files**, open -its book actions, and choose **Reset Book Reader Settings**. This removes -that book's saved reader choices so it follows the current global defaults -again; it does not reset your other books or global settings. Older saved books -may need this reset before they can inherit individual global changes. **Delete -Book Cache** does not reset these choices. +your global margins. If you open global settings through the pull-down panel +on a touch reader, inherited font and layout changes also apply to the open +book when you return. **Dark Mode** and **Stable Page Numbers** are global +settings. + +If a global change does not affect one EPUB, open its Reader Menu **Settings** +tab and choose **Reset Book Reader Settings**, or use the book action menu in +**Browse Files** or **Library**. This removes that book's saved reader choices +so it follows the current global defaults again; it keeps reading progress, +bookmarks, clippings, and stats, and does not reset your other books or global +settings. Older saved books may need this reset before they can inherit +individual global changes. **Delete Book Cache** does not reset these choices. - **Reader Font Family**: Choose the font used for reading: - "Lexend Deca" (default) - "Bitter" -- **Reader Font Size**: Adjust the text size for reading, built-in font sizes include: 10, 12, 14, and 16 pt. +- **Reader Font Size**: Choose from the sizes available for the selected font + and firmware build. ESP32-S3 readers use scalable TTF fonts with every whole + point size from **8–22 pt**; see [Scalable TTF Fonts](./scalable-fonts.md). - **Reader Line Spacing**: Adjust the line height as a percentage. - **Word Spacing**: In EPUB books, choose **Normal** or one of four wider - spacing levels between words. Open the reader menu, then select **Reader - Options > Font Options > Word Spacing**. Changing it reflows the current + spacing levels between words. Open the reader menu, then select **Font > + Line/Word Spacing > Word Spacing**. Changing it reflows the current book, so page positions may change; it is not available for TXT books. - **Reader Screen Margin**: Controls the screen margins in Reading Mode between 5 and 40 pixels in 5-pixel increments. @@ -304,22 +339,25 @@ Book Cache** does not reset these choices. - **Extra Paragraph Spacing**: Set how to handle paragraph breaks: - "ON" - Vertical space will be added between paragraphs in Reading Mode - - "OFF" - Paragraphs will not have vertical space added, but will have first-line indentation + - "OFF" - Paragraphs will not have vertical space added; source-defined first-line indentation is shown when available -- **Reader Dark Mode**, **Embedded Style**, **Images**, **Focus Reading**, and +- **Embedded Style**, **Images**, **Focus Reading**, and **Guide Dots** are directly available from the Reader settings. See [Reader Features](./reader-features.md) for their behavior, including the [Focus Reading](./reader-features.md#focus-reading) guide. -- **Touch Reader Controls**: Enable or disable touchscreen page turns and - reader-menu swipe gestures on supported devices. Device-specific full-screen - Home and frontlight gestures remain available as described in [Touch Reader - Controls](#touch-reader-controls). **Disable Touchscreen** blocks touch input - while a book is open, while leaving touch available in reader menus so you can - turn it back on. +- **Disable Touchscreen**: Block screen-touch input while a book is open, + while leaving touch available in reader menus so you can turn it back on. + To disable only touch page turns, use **Next Page** and **Previous Page** + in **Settings > Controls > Taps & Gestures**. -- **Status Bars**: Configure the status bars displayed while reading: +- **Status Bars**: Configure top and bottom bars separately for EPUB, TXT, and + XTC reading. Open **Settings > Reader > Status Bars**, or the EPUB Reader + Menu's **Settings > Status Bars**. Select a bar to preview it at its reading + position and choose its items: - Top and bottom bars each have three left slots, one center slot, and three right slots. + - Stable Page Numbers - Add a reference-page count to a slot when an EPUB has + CrossInk reference metadata; see [Stable Page Numbers](./reader-features.md#stable-page-numbers) - Chapter Page Count - Show/Hide the current page in the chapter (ex: 5/25). Page count may change based on the font size and margins set. - Book Progress Percentage - Show/Hide the current percent progress in the book. - Percentage Format - Show book progress as `10%` (default), `10.1%`, or `10.12%`. @@ -333,7 +371,7 @@ Book Cache** does not reset these choices. On touchscreen readers, when **Tap to Hide Status Bar** is enabled (the default in **Settings > Controls > Taps & Gestures**), tap the status-bar area while reading to show or hide the entire status bar for the current reading session. -This tap is available while **Touch Reader Controls** is enabled. The quick +This tap is available while reader touch input is enabled. The quick toggle does not change the page layout or page breaks; tap the same status-bar region again to restore a hidden bar. Use **Status Bars** to choose which status-bar items are shown. @@ -345,22 +383,26 @@ which status-bar items are shown. - **Front Buttons**: Configure front-button remapping, orientation awareness, reader-only long-press behavior, Back action, and Menu action. -- **Side Buttons**: Configure side-button layout, orientation awareness, and side-button long-press behavior. - -- **Side Button Layout (reader)**: Swap the order of the up and down buttons from "Prev/Next" (default) to "Next/Prev" or "Next/Next". You can also disable them entirely. This change is only in effect when reading. +- **Side Buttons**: Assign a **Short-press Action** and **Long-press Action** + separately for **Left/Up** and **Right/Down** while reading. Choose page or + chapter turns, font-size changes, **Rotate Page CCW**, **Rotate Page CW**, + **Rotate Page 180°**, or other available shortcuts. Set an action to **Ignore** + to leave that press unassigned. Existing layouts migrate to equivalent + individual actions. **Orientation aware** and the side-button chord setting + remain available on supported devices. Rotation and font-size actions apply + to EPUB/TXT text; they cannot rotate or resize pre-rendered XTC pages. - **Long-press Behavior**: Set whether long-pressing front page-turn buttons does nothing, skips to the next/previous chapter, or changes reader orientation. -- **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**: Choose what a short press or a hold of about 0.4 seconds does. Available actions include: +- **Power 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). + - "Next Page" - 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", "Library", or "Save Clipping" - Run the matching action + - "Footnotes" - Select a footnote reference on the current page, with a list + fallback when needed; see [Footnote Navigation](#footnote-navigation). - **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. @@ -377,6 +419,8 @@ which status-bar items are shown. readers. - **Tap to Hide Status Bar**: Enable or disable tapping the visible status-bar area to show or hide it for the current reading session. + - **Edge Gestures**: Assign upward and downward slides on the left and right + screen edges; see [Edge Gestures](./controls.md#edge-gestures). - **Two-finger Swipe** (multi-touch devices): Assign an action to each two-finger swipe direction. The available actions depend on the device and reader format. @@ -719,16 +763,18 @@ Once you have opened a book, the button layout changes to facilitate reading. | **Previous Page** | Press **Left** _or_ **Up** | | **Next Page** | Press **Right** _or_ **Down** | -The role of the side buttons can be swapped in **Settings > Controls > Side Buttons**. +Change the individual side-button actions in **Settings > Controls > Side Buttons**. -If the **Short-press Action** setting is set to "Page Turn", you can also turn to the next page by briefly pressing the Power button. +If the **Short-press Action** setting is set to "Next Page", you can also turn to the next page by briefly pressing the Power button. ### Chapter Navigation - **Next Chapter:** Press and **hold** the **Right** (or **Down**) button briefly, then release. - **Previous Chapter:** Press and **hold** the **Left** (or **Up**) button briefly, then release. -This feature can be disabled in **Settings > Controls > Front Buttons** to help avoid changing chapters by mistake. +These are the default chapter-skip actions. Change front-button long presses in +**Settings > Controls > Front Buttons**, or assign each side-button hold +separately in **Settings > Controls > Side Buttons**. ### Auto Page Turn @@ -740,9 +786,9 @@ On the **Xteink X3** and **Sticky**, the gyroscope can be used to turn pages by ### Touch Reader Controls -On supported touchscreen devices, **Touch Reader Controls** is enabled by -default. **Next Page** and **Previous Page**, in **Settings > Controls**, are -configured independently and both default to **Tap & Swipe**: +On supported touchscreen devices, touch page turns are enabled by default. +**Next Page** and **Previous Page**, in **Settings > Controls > Taps & Gestures**, +are configured independently and both default to **Tap & Swipe**: | Option | Taps | Swipes | | ---------------- | -------------------------------- | -------- | @@ -753,7 +799,9 @@ configured independently and both default to **Tap & Swipe**: | **Disabled** | Disabled | Disabled | Swipe left for the next page when **Next Page** allows swipes, or swipe right -for the previous page when **Previous Page** allows swipes. When both directions +for the previous page when **Previous Page** allows swipes. In right-to-left +EPUBs, normal tap zones and horizontal swipe directions follow the book's +reading direction instead. In left-to-right books, when both directions allow taps, the normal zones are the left third for the previous page and the right two-thirds for the next page. If either of those settings is **Inverted Tap**, the shared zones become the left two-thirds for the next page and the @@ -766,7 +814,8 @@ For **EPUB readers**, vertical gestures depend on the device: - On **Sticky**, swipe up to open the reader menu. Swipe down to open the reader-details/frontlight panel; use that panel's header to return Home. - On **X4 Pro**, swipe up to open the reader menu and swipe down to open the - frontlight panel. The capacitive Home key returns Home on a short press and + frontlight panel, which also shows chapter pages and book progress while + reading. The capacitive Home key returns Home on a short press and opens the reader menu on a long press by default. Configure these actions, or disable the key while reading, in **Settings > Controls > Home Button**. - On other touchscreen devices, swipe down to open the reader menu and swipe up @@ -781,9 +830,7 @@ settings above. Choose **Disabled** in **Next Page** or **Previous Page** to stop touch page turns in that direction without disabling the touchscreen's vertical reader-menu -or frontlight gestures. Turn **Touch Reader Controls** off in **Reader Options** -to disable one-finger page turns and reader-menu swipes; device-specific -full-screen Home/frontlight gestures remain available. **Disable Touchscreen** +or frontlight gestures. **Disable Touchscreen**, in **Settings > Reader**, prevents screen-touch input while a book is open but keeps it available in reader menus. For the different touch selection gestures used by [dictionary lookup](./dictionary.md#looking-up-a-word) and [clippings](./reader-features.md#clippings-and-highlights), see those feature guides. @@ -807,7 +854,7 @@ On supported multi-touch devices, enable **Pinch to Resize Font** in the same menu, then move two fingers apart to increase the font or together to decrease it. Each completed pinch changes one available font-size step. Pinch resizing works in EPUB and TXT readers; XTC pages are pre-rendered and cannot be -resized. Pinch input also requires **Touch Reader Controls** to remain enabled. +resized. Pinch input also requires reader touch input to remain enabled. ### Slider Controls @@ -816,7 +863,13 @@ the slider track rounds the selected value to the nearest multiple of five. ### Footnote Navigation -When reading an EPUB that contains footnotes, you can navigate to the footnote text by selecting the footnote reference in the book. From the footnote, you can return to your original reading position. +When reading an EPUB that contains footnotes, tap a reference or choose +**Footnotes** from the Reader Menu's **More** tab (or an assigned shortcut). +If there is one footnote, it opens directly. With several visible references, +CrossInk highlights them on the reading page: use the direction buttons to +choose one and **Confirm** to open it, or tap the reference. Links without a +visible target use a list instead. Press **Back** to return to your original +reading position. If the device goes to sleep or you close the book while viewing a footnote, the book reopens to your original reading position, not the footnote. @@ -834,7 +887,7 @@ CrossInk renders text using the following Unicode character blocks, enabling sup - **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others. - **Vietnamese:** Supported via extended Latin glyph coverage in the built-in reader fonts. -What is not supported with built-in reader fonts: Chinese, Japanese, Korean, Arabic, Greek, Hebrew, and Farsi. However, **CJK, Hebrew, Greek, and other extended scripts can be enabled by installing custom SD card fonts** — see [Custom Fonts (SD Card)](#38-custom-fonts-sd-card). +What is not supported with built-in reader fonts: Chinese, Japanese, Korean, Arabic, Greek, Hebrew, and Farsi. However, **CJK, Hebrew, Greek, and other extended scripts can be enabled by installing custom SD card fonts** — see [Custom Fonts (SD Card)](#39-custom-fonts-sd-card). --- @@ -842,34 +895,36 @@ What is not supported with built-in reader fonts: Chinese, Japanese, Korean, Ara Press **Confirm** while reading to open the Reader Menu. From here you can access reading utilities and navigation options without leaving the book. -Available options include: - -- **Select Chapter** – Open the table of contents to jump to a specific chapter (see [Chapter Selection](#51-chapter-selection) below). -- **Footnotes** – Navigate to the footnotes for the current section _(only shown in books that contain footnotes)_. -- **Reader Options** – Open reader-specific options without leaving the book. -- **Controls** – Open reader control options without leaving the book. -- **Reading Orientation** – Cycle through screen orientations without leaving the reader. -- **Auto Turn Interval** – Configure automatic page turns for hands-free reading. -- **Go to %** – Jump to a specific position in the book by percentage. -- **Add Bookmark / Remove Bookmark** – Toggle a bookmark on the current page. -- **View Bookmarks / Delete Bookmarks** – Manage existing bookmarks when the book has bookmarks. -- **Take screenshot** – Save a screenshot of the current page to the `screenshots/` folder. -- **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 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. +EPUB books use the same five icon tabs on touch and button devices: + +| Tab (icon) | Main options | +| --- | --- | +| **Font** (letters) | Reader Font, Dictionary Font, Line/Word Spacing, text anti-aliasing, Focus Reading, Guide Dots | +| **Layout** (text lines) | Margins, Orientation, Alignment, Images, Hyphenation, Publisher Page Numbers, paragraph spacing and publisher styling | +| **More** (three dots) | Word lookup, Select Chapter, Go to %, Go to Stable Page when available, Auto Page Turn, Footnotes; Reading Stats on button devices when tracking is enabled | +| **Bookmarks** (bookmark) | Add/remove and view bookmarks, create/view clippings, screenshot, position QR; Sync Progress, Nearby Position Sync, and Send Nearby Book on button devices | +| **Settings** (gear) | Status Bars, Controls, Book Dictionary, EPUB Render Mode, Indexing Method, finished status, Track Reading Stats, cache/stats resets, Reset Book Reader Settings | + +Some actions appear only when the book or device supports them. On touch +screens, tap a tab and then an option. On button devices, **Left/Right** switch +tabs, **Up/Down** select rows, and **Confirm** opens the selected option. Font, +spacing, and margin changes have a live preview. Global settings use the same +Left/Right tab and Up/Down row navigation. + +**Reset Book Reader Settings** restores the current EPUB's inherited global +reader defaults without deleting progress, bookmarks, clippings, or stats. +**Delete Book Cache** rebuilds cached book data and keeps those reader choices. + +TXT and XTC books have their own menus and a smaller set of options; the +five-tab layout above is for EPUBs. For stats tracking, use **Track Reading +Stats** in the EPUB **Settings** tab or the XTC Reader Menu. For format-specific +features, see [Reader Features](./reader-features.md). Press **Back** at any time to close the menu and return to your current page. ### 5.1 Chapter Selection -Accessible by selecting **Chapters** from the Reader Menu. +In EPUB books, open the Reader Menu's **More** tab and select **Select Chapter**. 1. Use **Left** (or **Up**), or **Right** (or **Down**) to highlight the desired chapter. 2. Press **Confirm** to jump to that chapter. diff --git a/include/DeviceCapabilities.h b/include/DeviceCapabilities.h index 23ba65d7e42..dfe0a23afd0 100644 --- a/include/DeviceCapabilities.h +++ b/include/DeviceCapabilities.h @@ -12,6 +12,16 @@ inline bool deviceHasEdgeSideButtons(const HalGPIO& gpio) { #endif } +// Menu navigation follows physical placement without changing the legacy +// layout rules used by sliders, step controls, and side-button hints. +inline bool deviceUsesHorizontalSideButtonsForMenus(const HalGPIO& gpio) { +#if defined(SIMULATOR_DEVICE_X4_PRO) || defined(SIMULATOR_DEVICE_X4_CLASSIC) + return true; +#else + return deviceHasEdgeSideButtons(gpio) || gpio.deviceIsX3(); +#endif +} + inline bool deviceUsesSideButtonHintGutters(const HalGPIO& gpio) { if (!deviceHasEdgeSideButtons(gpio)) return false; #if CROSSINK_APP_CAP_TOUCH diff --git a/lib/EpdFont/EpdFontFamily.cpp b/lib/EpdFont/EpdFontFamily.cpp index 36c64ee72cb..668b9368341 100644 --- a/lib/EpdFont/EpdFontFamily.cpp +++ b/lib/EpdFont/EpdFontFamily.cpp @@ -22,12 +22,25 @@ const EpdFont* EpdFontFamily::getFont(const Style style) const { return regular; } -void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const Style style) const { +void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const Style style, int* inkMinY, + int* inkMaxY) const { int minX = 0, minY = 0, maxX = 0, maxY = 0; + int boundsMinY = 0, boundsMaxY = 0; + bool hasInk = false; + const auto includeVerticalBounds = [&](const int bottom, const int top) { + minY = std::min(minY, bottom); + maxY = std::max(maxY, top); + if ((!inkMinY && !inkMaxY) || top <= bottom) return; + boundsMinY = hasInk ? std::min(boundsMinY, bottom) : bottom; + boundsMaxY = hasInk ? std::max(boundsMaxY, top) : top; + hasInk = true; + }; if (*string == '\0') { *w = 0; *h = 0; + if (inkMinY) *inkMinY = 0; + if (inkMaxY) *inkMaxY = 0; return; } @@ -75,8 +88,7 @@ void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const minX = std::min(minX, lastBaseX + glyphLeft); maxX = std::max(maxX, lastBaseX + glyphLeft + glyphWidth); - minY = std::min(minY, glyphTop - glyphHeight); - maxY = std::max(maxY, glyphTop); + includeVerticalBounds(glyphTop - glyphHeight, glyphTop); lastBaseLeft = glyphLeft; lastBaseWidth = glyphWidth; @@ -101,8 +113,7 @@ void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const minX = std::min(minX, lastBaseX + glyphLeft); maxX = std::max(maxX, lastBaseX + glyphLeft + glyphWidth); - minY = std::min(minY, glyphTop - glyphHeight); - maxY = std::max(maxY, glyphTop); + includeVerticalBounds(glyphTop - glyphHeight, glyphTop); lastBaseLeft = glyphLeft; lastBaseWidth = glyphWidth; @@ -127,8 +138,7 @@ void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const minX = std::min(minX, lastBaseX + glyphLeft); maxX = std::max(maxX, lastBaseX + glyphLeft + glyphWidth); - minY = std::min(minY, glyphTop - glyphHeight); - maxY = std::max(maxY, glyphTop); + includeVerticalBounds(glyphTop - glyphHeight, glyphTop); lastBaseLeft = glyphLeft; lastBaseWidth = glyphWidth; @@ -162,12 +172,11 @@ void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const const int glyphBaseX = isCombining ? combiningMark::anchorOver(anchor, lastBaseX, lastBaseLeft, lastBaseWidth, glyph->left, glyph->width) : lastBaseX; - const int glyphBaseY = -raiseBy; + const int glyphBaseY = raiseBy; minX = std::min(minX, glyphBaseX + glyph->left); maxX = std::max(maxX, glyphBaseX + glyph->left + glyph->width); - minY = std::min(minY, glyphBaseY + glyph->top - glyph->height); - maxY = std::max(maxY, glyphBaseY + glyph->top); + includeVerticalBounds(glyphBaseY + glyph->top - glyph->height, glyphBaseY + glyph->top); if (!isCombining) { lastBaseLeft = glyph->left; @@ -180,6 +189,8 @@ void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const *w = maxX - minX; *h = maxY - minY; + if (inkMinY) *inkMinY = boundsMinY; + if (inkMaxY) *inkMaxY = boundsMaxY; } const EpdFontData* EpdFontFamily::getData(const Style style) const { return getFont(style)->data; } diff --git a/lib/EpdFont/EpdFontFamily.h b/lib/EpdFont/EpdFontFamily.h index 29db239b4fb..b8a62f8c406 100644 --- a/lib/EpdFont/EpdFontFamily.h +++ b/lib/EpdFont/EpdFontFamily.h @@ -26,7 +26,9 @@ class EpdFontFamily { const EpdFont* boldItalic = nullptr, const EpdFont* fallback = nullptr) : regular(regular), bold(bold), italic(italic), boldItalic(boldItalic), fallback(fallback) {} ~EpdFontFamily() = default; - void getTextDimensions(const char* string, int* w, int* h, Style style = REGULAR) const; + // Optional ink bounds use font coordinates, increasing upward from the baseline. + void getTextDimensions(const char* string, int* w, int* h, Style style = REGULAR, int* inkMinY = nullptr, + int* inkMaxY = nullptr) const; const EpdFontData* getData(Style style = REGULAR) const; GlyphData findGlyphData(uint32_t cp, Style style = REGULAR) const; GlyphData getGlyphData(uint32_t cp, Style style = REGULAR) const; diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index 578107e14e3..0014515fffe 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -13,7 +13,7 @@ namespace { constexpr uint32_t BOOK_CACHE_MAGIC = 0x425843FF; // bytes: 0xFF, "CXB" -constexpr uint8_t BOOK_CACHE_VERSION = 9; // v9: NFC titles and updated guide start-reference handling +constexpr uint8_t BOOK_CACHE_VERSION = 10; // v10: namespace-aware OPF parsing constexpr char bookBinFile[] = "/book.bin"; constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; diff --git a/lib/Epub/Epub/InlineImageToken.h b/lib/Epub/Epub/InlineImageToken.h new file mode 100644 index 00000000000..67ad36a1207 --- /dev/null +++ b/lib/Epub/Epub/InlineImageToken.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include + +// Temporary layout token. It is removed before TextBlock serialization; the +// corresponding image is stored as an ordinary PageImage. +inline std::string makeInlineImageToken(uint16_t id, uint16_t width, uint16_t height) { + char token[15]; + snprintf(token, sizeof(token), "\x01I%04X%04X%04X", id, width, height); + return token; +} + +inline bool parseInlineImageToken(const char* token, uint16_t& id, uint16_t& width, uint16_t& height) { + if (!token || token[0] != '\x01' || token[1] != 'I' || strlen(token) != 14) return false; + uint16_t values[3] = {}; + for (int group = 0; group < 3; ++group) { + uint16_t value = 0; + for (int digit = 0; digit < 4; ++digit) { + const char c = token[2 + group * 4 + digit]; + uint8_t nibble; + if (c >= '0' && c <= '9') + nibble = static_cast(c - '0'); + else if (c >= 'A' && c <= 'F') + nibble = static_cast(c - 'A' + 10); + else + return false; + value = static_cast((value << 4) | nibble); + } + values[group] = value; + } + if (token[14] != '\0' || values[1] == 0 || values[2] == 0) return false; + id = values[0]; + width = values[1]; + height = values[2]; + return true; +} diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 4cc7c62dcad..f34cf2c151b 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -87,7 +87,8 @@ void PageImage::renderPlaceholder(GfxRenderer& renderer, const int xOffset, cons } bool PageImage::serialize(FsFile& file) { - if (!serialization::tryWritePod(file, xPos) || !serialization::tryWritePod(file, yPos)) { + if (!serialization::tryWritePod(file, xPos) || !serialization::tryWritePod(file, yPos) || + !serialization::tryWritePod(file, static_cast(inlineImage))) { LOG_ERR("PGE", "Serialization failed: could not write PageImage coordinates"); return false; } @@ -99,7 +100,9 @@ bool PageImage::serialize(FsFile& file) { std::unique_ptr PageImage::deserialize(FsFile& file) { int16_t xPos; int16_t yPos; - if (!serialization::tryReadPod(file, xPos) || !serialization::tryReadPod(file, yPos)) { + uint8_t inlineFlag; + if (!serialization::tryReadPod(file, xPos) || !serialization::tryReadPod(file, yPos) || + !serialization::tryReadPod(file, inlineFlag) || inlineFlag > 1) { LOG_ERR("PGE", "Deserialization failed: truncated PageImage coordinates"); return nullptr; } @@ -110,7 +113,7 @@ std::unique_ptr PageImage::deserialize(FsFile& file) { return nullptr; } - auto* pageImage = new (std::nothrow) PageImage(std::move(ib), xPos, yPos); + auto* pageImage = new (std::nothrow) PageImage(std::move(ib), xPos, yPos, inlineFlag != 0); if (!pageImage) { LOG_ERR("PGE", "Deserialization failed: could not allocate PageImage"); return nullptr; @@ -501,22 +504,32 @@ void Page::renderWithImagePlaceholders(GfxRenderer& renderer, const int fontId, } } -uint16_t Page::imageEstimateUnits(const uint16_t viewportHeight) const { +uint16_t Page::imageEstimateUnits(const uint16_t viewportWidth, const uint16_t viewportHeight) const { bool hasImage = false; + bool hasBlockImage = false; bool hasReadableContent = false; - uint32_t imageHeight = 0; + uint64_t imageArea = 0; for (const auto& element : elements) { switch (element->getTag()) { case TAG_PageImage: { hasImage = true; - const auto& image = static_cast(*element).getImageBlock(); - if (viewportHeight > 0) { + const auto& pageImage = static_cast(*element); + const auto& image = pageImage.getImageBlock(); + hasBlockImage = hasBlockImage || !pageImage.isInlineImage(); + if (viewportWidth > 0 && viewportHeight > 0) { const int imageTop = std::max(0, static_cast(element->yPos)); const int imageBottom = std::min(static_cast(viewportHeight), static_cast(element->yPos) + std::max(0, static_cast(image.getHeight()))); if (imageBottom > imageTop) { - imageHeight += static_cast(imageBottom - imageTop); + const uint32_t visibleHeight = static_cast(imageBottom - imageTop); + // Inline icons occupy only part of a text line. Protect their + // approximate area instead of counting every icon as a full-width + // strip, which skews estimates for image-rich paragraphs. + const uint32_t countedWidth = + pageImage.isInlineImage() ? static_cast(std::clamp(image.getWidth(), 0, viewportWidth)) + : viewportWidth; + imageArea += static_cast(visibleHeight) * countedWidth; } } break; @@ -531,10 +544,12 @@ uint16_t Page::imageEstimateUnits(const uint16_t viewportHeight) const { } if (!hasImage) return 0; - if (!hasReadableContent || viewportHeight == 0) return PageCountEstimator::kUnitsPerPage; + if ((!hasReadableContent && hasBlockImage) || viewportWidth == 0 || viewportHeight == 0) + return PageCountEstimator::kUnitsPerPage; - const uint32_t units = (imageHeight * PageCountEstimator::kUnitsPerPage) / viewportHeight; - return static_cast(std::min(PageCountEstimator::kUnitsPerPage, units)); + const uint64_t units = + (imageArea * PageCountEstimator::kUnitsPerPage) / (static_cast(viewportWidth) * viewportHeight); + return static_cast(std::min(PageCountEstimator::kUnitsPerPage, units)); } bool Page::serialize(FsFile& file) const { diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 20d3769ba91..c1522eb17e2 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -49,16 +49,18 @@ class PageLine final : public PageElement { // New PageImage class class PageImage final : public PageElement { std::unique_ptr imageBlock; + bool inlineImage = false; public: - PageImage(std::unique_ptr block, const int16_t xPos, const int16_t yPos) - : PageElement(xPos, yPos), imageBlock(std::move(block)) {} + PageImage(std::unique_ptr block, const int16_t xPos, const int16_t yPos, const bool inlineImage = false) + : PageElement(xPos, yPos), imageBlock(std::move(block)), inlineImage(inlineImage) {} void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset, bool foregroundBlack = true) override; void renderPlaceholder(GfxRenderer& renderer, int xOffset, int yOffset, bool foregroundBlack) const; bool serialize(FsFile& file) override; PageElementTag getTag() const override { return TAG_PageImage; } static std::unique_ptr deserialize(FsFile& file); const ImageBlock& getImageBlock() const { return *imageBlock; } + bool isInlineImage() const { return inlineImage; } }; class PageHorizontalRule final : public PageElement { @@ -205,7 +207,7 @@ class Page { // Return the fixed-point page units protected by images on this page. Text // pages return zero; image-only pages are one full page (256 units), while // mixed pages contribute their visible image-height fraction. - uint16_t imageEstimateUnits(uint16_t viewportHeight) const; + uint16_t imageEstimateUnits(uint16_t viewportWidth, uint16_t viewportHeight) const; // Check if page contains any images (used to force full refresh) bool hasImages() const { diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index e09b5fee6fb..8e236697917 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -15,6 +15,7 @@ #include #include +#include "InlineImageToken.h" #include "hyphenation/Hyphenator.h" constexpr int MAX_COST = std::numeric_limits::max(); @@ -377,6 +378,8 @@ int measureFocusRunOffset(const GfxRenderer& renderer, const int fontId, const s uint16_t measureTokenWidth(const GfxRenderer& renderer, const int fontId, const std::string& word, const EpdFontFamily::Style style, const uint8_t focusBoundary, const bool appendHyphen = false) { + uint16_t imageId, imageWidth, imageHeight; + if (parseInlineImageToken(word.c_str(), imageId, imageWidth, imageHeight)) return imageWidth; if (focusBoundary == 0 || focusBoundary >= word.size() || appendHyphen || containsSoftHyphen(word)) { return measureWordWidth(renderer, fontId, word, style, appendHyphen); } @@ -419,6 +422,9 @@ int guideDotWordSpacingExtra(const GfxRenderer& renderer, const int fontId, cons int naturalGapBeforeToken(const GfxRenderer& renderer, const int fontId, const std::string& leftWord, const std::string& rightWord, const EpdFontFamily::Style leftStyle, const bool continues, const bool noSpaceBefore, const bool guideDotBefore, const uint8_t wordSpacing) { + uint16_t imageId, imageWidth, imageHeight; + const bool imageEdge = parseInlineImageToken(leftWord.c_str(), imageId, imageWidth, imageHeight) || + parseInlineImageToken(rightWord.c_str(), imageId, imageWidth, imageHeight); if (guideDotBefore) { const int extraGap = guideDotWordSpacingExtra(renderer, fontId, leftWord, rightWord, leftStyle, wordSpacing); return guideDotNaturalGap(renderer, fontId, leftWord, rightWord, leftStyle) + extraGap; @@ -427,15 +433,24 @@ int naturalGapBeforeToken(const GfxRenderer& renderer, const int fontId, const s return 0; } if (continues) { + if (imageEdge) return 0; return renderer.getKerning(fontId, lastCodepoint(leftWord), firstCodepoint(rightWord), leftStyle); } + if (imageEdge) { + const int gap = renderer.getSpaceWidth(fontId, leftStyle); + return gap + wordSpacingExtraFromGap(gap, wordSpacing); + } const int naturalGap = renderer.getSpaceAdvance(fontId, lastCodepoint(leftWord), firstCodepoint(rightWord), leftStyle); return naturalGap + wordSpacingExtraFromGap(naturalGap, wordSpacing); } -size_t gapSlotsBeforeToken(const std::string& rightWord, const bool continues, const bool noSpaceBefore, - const bool guideDotBefore) { +size_t gapSlotsBeforeToken(const std::string& leftWord, const std::string& rightWord, const bool continues, + const bool noSpaceBefore, const bool guideDotBefore) { + uint16_t imageId, imageWidth, imageHeight; + if ((continues || noSpaceBefore) && (parseInlineImageToken(leftWord.c_str(), imageId, imageWidth, imageHeight) || + parseInlineImageToken(rightWord.c_str(), imageId, imageWidth, imageHeight))) + return 0; if (guideDotBefore) { return guideDotGapSlots(rightWord); } @@ -655,6 +670,13 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, bool effectiveAttachToPrevious = attachToPrevious; bool effectiveNoSpaceBefore = false; + uint16_t imageId, imageWidth, imageHeight; + if (attachToPrevious && !words.empty() && + parseInlineImageToken(words.back().c_str(), imageId, imageWidth, imageHeight)) { + // The following text may wrap after the image without gaining a space. + effectiveAttachToPrevious = false; + effectiveNoSpaceBefore = true; + } // Only a glued token (attachToPrevious == true, i.e. no whitespace separated it from the // previous one in the source) may be turned into a gap-less break opportunity. When real // whitespace separated the two words, that space is content and must be rendered: Korean @@ -666,7 +688,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, } // GUIDE READING: store a virtual middle dot (U+00B7) before the next real token. - if (guideReadingEnabled && !effectiveAttachToPrevious && !effectiveNoSpaceBefore && !words.empty()) { + if (guideReadingEnabled && !effectiveAttachToPrevious && !effectiveNoSpaceBefore && !words.empty() && + !parseInlineImageToken(words.back().c_str(), imageId, imageWidth, imageHeight)) { guideDotBeforeNextToken = true; } @@ -793,6 +816,23 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, } } +void ParsedText::addInlineImage(const uint16_t id, const uint16_t width, const uint16_t height, + const bool attachToPrevious, const uint32_t visibleTextOffset, + const uint32_t referenceTextOffset) { + reserveTokenCapacity(1); + words.push_back(makeInlineImageToken(id, width, height)); + wordStyles.push_back(EpdFontFamily::REGULAR); + // Image edges are legal wrap points even with no source whitespace. + wordContinues.push_back(false); + wordNoSpaceBefore.push_back(attachToPrevious); + wordFocusBoundary.push_back(0); + wordGuideDotBefore.push_back(false); + wordBackgroundBlack.push_back(0); + pushVisibleOffset(visibleTextOffset); + if (trackReferenceOffsets) wordReferenceOffsets.push_back(referenceTextOffset); + if (!rubyTexts.empty()) rubyTexts.push_back(""); +} + void ParsedText::setRubyForWordAt(size_t index, const std::string& ruby) { if (index >= words.size()) return; if (rubyTexts.size() <= index) { @@ -1150,8 +1190,8 @@ bool ParsedText::calculateGapMetrics(ArenaVector& naturalGaps, ArenaVec naturalGaps[i] = static_cast(naturalGapBeforeToken(renderer, fontId, words[i - 1], words[i], wordStyles[i - 1], continues, noSpaceBefore, guideDotBefore, wordSpacing)); - gapSlots[i] = static_cast( - std::min(UINT8_MAX, gapSlotsBeforeToken(words[i], continues, noSpaceBefore, guideDotBefore))); + gapSlots[i] = static_cast(std::min( + UINT8_MAX, gapSlotsBeforeToken(words[i - 1], words[i], continues, noSpaceBefore, guideDotBefore))); } return true; } @@ -1374,6 +1414,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl } const std::string& word = words[wordIndex]; + uint16_t imageId, imageWidth, imageHeight; + if (parseInlineImageToken(word.c_str(), imageId, imageWidth, imageHeight)) return false; const auto style = wordStyles[wordIndex]; const int leadingPadding = inlinePaddingBefore(wordIndex); @@ -1513,6 +1555,8 @@ bool ParsedText::splitTokenAtCodepointBoundary(const size_t wordIndex, const int } const std::string& word = words[wordIndex]; + uint16_t imageId, imageWidth, imageHeight; + if (parseInlineImageToken(word.c_str(), imageId, imageWidth, imageHeight)) return false; if (word.size() < 2) { return false; } @@ -1767,9 +1811,9 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const for (size_t wordIdx = 0; wordIdx < reorderedWidths.size(); ++wordIdx) { reorderedWordWidthSum += reorderedWidths[wordIdx]; if (wordIdx > 0) { - reorderedGapCount += - gapSlotsBeforeToken(reorderedWordsScratch[wordIdx], reorderedContinuesScratch[wordIdx], - reorderedNoSpaceBeforeScratch[wordIdx], reorderedGuideDotBeforeScratch[wordIdx]); + reorderedGapCount += gapSlotsBeforeToken( + reorderedWordsScratch[wordIdx - 1], reorderedWordsScratch[wordIdx], reorderedContinuesScratch[wordIdx], + reorderedNoSpaceBeforeScratch[wordIdx], reorderedGuideDotBeforeScratch[wordIdx]); reorderedNaturalGaps += naturalGapBeforeToken( renderer, fontId, reorderedWordsScratch[wordIdx - 1], reorderedWordsScratch[wordIdx], reorderedStylesScratch[wordIdx - 1], reorderedContinuesScratch[wordIdx], @@ -1818,8 +1862,9 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const int gap = naturalGapBeforeToken(renderer, fontId, reorderedWordsScratch[wordIdx], reorderedWordsScratch[wordIdx + 1], reorderedStylesScratch[wordIdx], nextContinues, nextNoSpace, nextGuideDot, wordSpacing); - gap += reorderedJustifyExtra * static_cast(gapSlotsBeforeToken(reorderedWordsScratch[wordIdx + 1], - nextContinues, nextNoSpace, nextGuideDot)); + gap += reorderedJustifyExtra * + static_cast(gapSlotsBeforeToken(reorderedWordsScratch[wordIdx], reorderedWordsScratch[wordIdx + 1], + nextContinues, nextNoSpace, nextGuideDot)); xpos += gap; } } @@ -1912,6 +1957,8 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const std::vector outRunOffsets; std::vector outGuideDotXOffset; std::vector outBackgroundBlack; + std::vector outRubyTexts; + lineImagesScratch.clear(); auto& outHasSpaceBefore = lineHasSpaceBeforeScratch; outWords.reserve(lineWordCount); outXPos.reserve(lineWordCount); @@ -1930,6 +1977,11 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const } for (size_t i = 0; i < lineWordCount; i++) { + uint16_t imageId, imageWidth, imageHeight; + if (parseInlineImageToken(lineWords[i].c_str(), imageId, imageWidth, imageHeight)) { + lineImagesScratch.push_back({imageId, lineXPos[i], imageHeight}); + continue; + } const uint8_t boundary = lineFocusBoundary[i]; const bool wordIsRtl = BidiUtils::detectParagraphLevel(lineWords[i].c_str(), blockStyle.isRtl ? 1 : 0) == static_cast(BidiUtils::BidiBaseDir::RTL); @@ -1939,6 +1991,7 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const : 0; outWords.push_back(std::move(lineWords[i])); + if (!lineRubyTexts.empty()) outRubyTexts.push_back(std::move(lineRubyTexts[i])); outXPos.push_back(lineXPos[i]); outStyles.push_back(lineWordStyles[i]); const bool continues = willReorder ? reorderedContinuesScratch[i] : wordContinues[lastBreakAt + i]; @@ -1950,7 +2003,7 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const } if (lineHasGuideDot) { outGuideDotXOffset.push_back(0); - if (i > 0 && lineGuideDotBefore[i]) { + if (outWords.size() > 1 && lineGuideDotBefore[i]) { const int wordSpacingSecondHalf = guideDotWordSpacingExtra(renderer, fontId, outWords[outWords.size() - 2], outWords.back(), outStyles[outStyles.size() - 2], wordSpacing) / @@ -1972,7 +2025,7 @@ bool ParsedText::extractLine(Arena& scratchArena, const size_t breakIndex, const auto block = std::make_shared(outWords, outXPos, outStyles, outBoundaries, outRunOffsets, outGuideDotXOffset, - outBackgroundBlack, outHasSpaceBefore, blockStyle, std::move(lineRubyTexts)); + outBackgroundBlack, outHasSpaceBefore, blockStyle, std::move(outRubyTexts)); if (!block->valid()) { LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed"); return false; diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index b65cd6a8806..cf31c6cc6a0 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -18,6 +18,14 @@ template class ArenaVector; class ParsedText { + public: + struct InlineImagePlacement { + uint16_t id; + int16_t x; + uint16_t height; + }; + + private: // words/rubyTexts are std::deque, not std::vector: a paragraph can hold thousands // of tokens (CJK splits every character), and a vector grows by reallocating its // whole element array into one contiguous block (32 B/std::string -> 64-128 KB at @@ -84,6 +92,7 @@ class ParsedText { std::vector lineBackgroundBlackScratch; std::vector lineLeadingPaddingScratch; std::vector visualOrderScratch; + std::vector lineImagesScratch; void reserveTokenCapacity(size_t additionalTokens); int resolveFirstLineIndent(bool isFirstLine, const GfxRenderer& renderer, int fontId) const; @@ -138,6 +147,9 @@ class ParsedText { void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false, bool backgroundBlack = false, uint8_t linkId = 0, uint32_t visibleTextOffset = 0, uint32_t referenceTextOffset = 0, int16_t leadingPadding = 0); + void addInlineImage(uint16_t id, uint16_t width, uint16_t height, bool attachToPrevious, uint32_t visibleTextOffset, + uint32_t referenceTextOffset); + const std::vector& currentLineImages() const { return lineImagesScratch; } void setRubyForWordAt(size_t index, const std::string& ruby); void setRubyGroupAt(size_t startIndex, size_t count, const std::string& ruby); EpdFontFamily::Style getWordStyleAt(size_t index) const { @@ -150,6 +162,7 @@ class ParsedText { size_t size() const { return words.size(); } bool isEmpty() const { return words.empty(); } bool isContinuation() const { return isContinuation_; } + void setContinuation(bool continuation) { isContinuation_ = continuation; } bool layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth, const std::function, uint32_t, uint32_t)>& processLine, bool includeLastLine = true); diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index d7a0b8eecd6..78c82c0c812 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -29,11 +29,13 @@ constexpr uint32_t SECTION_CACHE_MAGIC = 0x535843FF; // bytes: 0xFF, "CXS" // v77: Ordered lists, marker suppression, and list-container insets affect page layout. // v78: Inline CSS padding affects dialogue and other styled text positions. // v79: Hangul word boundaries and line-end splits change cached page positions. -constexpr uint8_t SECTION_FILE_VERSION = 79; +// v80: Small EPUB images can share text lines, changing cached page positions. +// v81: HTML and body text indents now inherit into descendant blocks. +constexpr uint8_t SECTION_FILE_VERSION = 81; // 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 = 0xF4; +constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xC2; 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) + @@ -156,7 +158,7 @@ uint32_t Section::onPageComplete(std::unique_ptr page) { // Scan the page before serializing it so image-only and mixed pages can be // protected from the later XHTML byte-density projection without changing // the serialized page payload. - const uint16_t imageUnits = page->imageEstimateUnits(imageEstimateViewportHeight_); + const uint16_t imageUnits = page->imageEstimateUnits(imageEstimateViewportWidth_, imageEstimateViewportHeight_); if (!page->serialize(file)) { LOG_ERR("SCT", "Failed to serialize page %d", builtPageCount_); return 0; @@ -451,6 +453,7 @@ bool Section::createSectionFile(const ReaderRenderSpec& spec, const std::functio pageCount = 0; builtPageCount_ = 0; imageEstimateViewportHeight_ = viewportHeight; + imageEstimateViewportWidth_ = viewportWidth; protectedImageUnits_ = 0; if (layoutAbortedForLowMemory) *layoutAbortedForLowMemory = false; if (buildOptions.cancellationObserved) *buildOptions.cancellationObserved = false; @@ -810,6 +813,7 @@ bool Section::startBuild(const ReaderRenderSpec& spec, const SectionBuildOptions const auto tmpSectionPath = binTmpPath(); builtPageCount_ = 0; imageEstimateViewportHeight_ = viewportHeight; + imageEstimateViewportWidth_ = viewportWidth; protectedImageUnits_ = 0; pageCount = partial_ ? partialPageCount_ : 0; buildComplete_ = false; diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 55ab7257bf6..687254db4e4 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -68,6 +68,7 @@ class Section { bool lastImagesWereSuppressed_ = false; bool lastLayoutAbortedForLowMemory_ = false; uint16_t imageEstimateViewportHeight_ = 0; + uint16_t imageEstimateViewportWidth_ = 0; uint32_t protectedImageUnits_ = 0; // Pages laid out by the active build. Distinct from pageCount, which is the pages // available to read and may include a loaded partial file's pages. diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index 121fb9e50b0..b26e1a531c0 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -495,7 +495,9 @@ void CssParser::parseDeclarationIntoStyle(std::string_view decl, CssStyle& style } } else if (iequalsAscii(name, "display")) { const std::string_view displayValue = stripTrailingImportant(value); - style.display = iequalsAscii(displayValue, "none") ? CssDisplay::None : CssDisplay::Block; + style.display = iequalsAscii(displayValue, "none") ? CssDisplay::None + : iequalsAscii(displayValue, "inline") ? CssDisplay::Inline + : CssDisplay::Block; style.defined.display = 1; } else if (iequalsAscii(name, "background") || iequalsAscii(name, "background-color")) { bool backgroundBlack = false; diff --git a/lib/Epub/Epub/css/CssParser.h b/lib/Epub/Epub/css/CssParser.h index ed222a4b0b9..39bc5bf776d 100644 --- a/lib/Epub/Epub/css/CssParser.h +++ b/lib/Epub/Epub/css/CssParser.h @@ -56,7 +56,7 @@ class CssParser { // Bump when CSS cache format or rules change; section caches are invalidated when this changes static constexpr uint32_t CSS_CACHE_MAGIC = 0x435843FF; // bytes: 0xFF, "CXC" - static constexpr uint8_t CSS_CACHE_VERSION = 18; + static constexpr uint8_t CSS_CACHE_VERSION = 19; // Source text is streamed, never loaded as one allocation. PSRAM readers // can admit larger publisher stylesheets; rule-count and internal-heap diff --git a/lib/Epub/Epub/css/CssStyle.h b/lib/Epub/Epub/css/CssStyle.h index 95586f9c150..58dd362078c 100644 --- a/lib/Epub/Epub/css/CssStyle.h +++ b/lib/Epub/Epub/css/CssStyle.h @@ -69,8 +69,8 @@ constexpr CssTextDecoration operator&(const CssTextDecoration a, const CssTextDe constexpr uint8_t CSS_TEXT_DECORATION_MASK = static_cast(CssTextDecoration::Underline) | static_cast(CssTextDecoration::LineThrough); -// Display options - only None and Block are relevant for e-ink rendering -enum class CssDisplay : uint8_t { Block = 0, None = 1 }; +// Keep inline distinct so replaced elements can participate in text layout. +enum class CssDisplay : uint8_t { Block = 0, None = 1, Inline = 2 }; // Vertical alignment options for inline elements (e.g. superscript/subscript) enum class CssVerticalAlign : uint8_t { Baseline = 0, Super = 1, Sub = 2 }; @@ -170,7 +170,7 @@ struct CssStyle { CssLength paddingRight; // Padding right CssLength imageHeight; // Height for img (e.g. 2em) – width derived from aspect ratio when only height set CssLength imageWidth; // Width for img when both or only width set - CssDisplay display = CssDisplay::Block; // display property (Block or None) + CssDisplay display = CssDisplay::Block; bool backgroundBlack = false; // Simple black inline/block background support CssVerticalAlign verticalAlign = CssVerticalAlign::Baseline; // vertical-align (super/sub positioning) bool pageBreakBefore = false; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 22b37068cc8..3612f41b10d 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -758,7 +758,7 @@ uint16_t ChapterHtmlSlimParser::textRunBytesBeforeLayoutLimit() const { return DEFAULT_TEXT_RUN_BYTES_BEFORE_LAYOUT; } -void ChapterHtmlSlimParser::flushLongTextRunIfNeeded(const bool force) { +void ChapterHtmlSlimParser::flushLongTextRunIfNeeded(const bool force, const bool flushLastLine) { if (!currentTextBlock) { currentTextRunBytes = 0; return; @@ -782,11 +782,12 @@ void ChapterHtmlSlimParser::flushLongTextRunIfNeeded(const bool force) { [this](const std::shared_ptr& textBlock, const uint32_t offset, const uint32_t referenceOffset) { addLineToPage(textBlock, offset, referenceOffset); }, - false)) { + flushLastLine)) { LOG_ERR("EHP", "Failed to lay out long text run"); lowMemoryAbort = true; return; } + if (flushLastLine) currentTextBlock->setContinuation(true); currentTextRunBytes = 0; } @@ -802,6 +803,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { if (currentTextBlock) { // already have a text block running and it is empty - just reuse it if (currentTextBlock->isEmpty()) { + currentTextBlock->setContinuation(false); BlockStyle incoming = blockStyle; const bool currentIsEmptyBr = currentTextBlock->getBlockStyle().fromBrElement; if (currentIsEmptyBr) { @@ -2245,7 +2247,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* return; } - // Skip image if CSS display:none + // Skip hidden images and retain explicit block placement in all render modes. + bool displayBlockImage = false; + bool displayInlineImage = false; if (self->cssParser) { CssStyle imgDisplayStyle = self->usesSimpleCssLookup() ? self->cssParser->resolveStyle("img", classAttr) @@ -2257,6 +2261,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->skipCurrentElement(); return; } + displayBlockImage = imgDisplayStyle.hasDisplay() && imgDisplayStyle.display == CssDisplay::Block; + displayInlineImage = imgDisplayStyle.hasDisplay() && imgDisplayStyle.display == CssDisplay::Inline; } if (!src.empty() && self->imageRendering != 1) { @@ -2443,6 +2449,42 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* return; } + // Small images participate in the text line's width and height. + // Full-width art and explicit block images retain the existing + // centered, page-breaking placement below. + const bool inlineImage = + self->currentTextBlock && self->tableDepth == 0 && !self->inRuby && displayWidth > 0 && + displayHeight > 0 && displayWidth < containerWidth && + (displayInlineImage || (imgStyle.hasDisplay() && imgStyle.display == CssDisplay::Inline) || + displayHeight <= self->effectiveLineHeight() * 2) && + !displayBlockImage && !(imgStyle.hasDisplay() && imgStyle.display == CssDisplay::Block); + if (inlineImage) { + const bool attachToPrevious = self->partWordBufferIndex > 0 || self->nextWordContinues; + if (self->partWordBufferIndex > 0) self->flushPartWordBuffer(); + auto imageBlock = makeUniqueNoThrow(std::move(cachedImagePath), std::move(sourcePath), + displayWidth, displayHeight); + if (!imageBlock) { + LOG_ERR("EHP", "Failed to create inline ImageBlock"); + self->lowMemoryAbort = true; + return; + } + const uint16_t imageId = self->nextInlineImageId++; + self->pendingInlineImages.push_back({imageId, std::move(imageBlock)}); + self->currentTextBlock->addInlineImage(imageId, static_cast(displayWidth), + static_cast(displayHeight), attachToPrevious, + self->visibleTextOffset, self->referenceTextOffset); + self->nextWordContinues = true; + // An icon-only run has no characterData callback to trigger + // the usual bounded paragraph flush. Seal the current line + // after this many tiny images so C3 memory stays bounded. + const bool flushImageLine = self->pendingInlineImages.size() >= MAX_PENDING_INLINE_IMAGES; + self->flushLongTextRunIfNeeded(flushImageLine, flushImageLine); + if (self->lowMemoryAbort) return; + self->pushCssAncestor(self->depth, name, classAttr); + self->depth += 1; + return; + } + // Flush any pending text block so it appears before the image if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); @@ -2676,6 +2718,16 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* stripPublisherSpacing(userAlignmentBlockStyle); } + // and are transparent to the block layout stack, but CSS + // text-indent is inherited by descendant paragraphs. Preserve that value on + // the root style so ordinary child-block merging applies paragraph overrides. + if ((strcmp(name, "html") == 0 || strcmp(name, "body") == 0) && self->blockStyleCount_ > 0 && + userAlignmentBlockStyle.textIndentDefined) { + auto& rootBlockStyle = self->blockStyleBuf_[0]; + rootBlockStyle.textIndent = userAlignmentBlockStyle.textIndent; + rootBlockStyle.textIndentDefined = true; + } + // Force paragraph indent to prevent unreadable walls of text. // This applies if the publisher set text-indent: 0, omitted it, or if it was stripped by disabling embedded styles. if (self->forceParagraphIndents && strcmp(name, "p") == 0) { @@ -3694,6 +3746,8 @@ void ChapterHtmlSlimParser::releaseInputFile() { } bool ChapterHtmlSlimParser::beginParse() { + pendingInlineImages.clear(); + nextInlineImageId = 1; malformedMarkupTruncated = false; htmlEnded_ = false; parseFileOffset_ = 0; @@ -3835,6 +3889,7 @@ ChapterHtmlSlimParser::ParseStatus ChapterHtmlSlimParser::parseStep() { } void ChapterHtmlSlimParser::abortParse() { + pendingInlineImages.clear(); if (activeParser) { destroyXmlParser(activeParser); activeParser = nullptr; @@ -3942,7 +3997,10 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line, const return; } - const int lineHeight = effectiveLineHeight() + line->getRubyShift(renderer.getFontAscenderSize(fontId)); + const auto& lineImages = currentTextBlock->currentLineImages(); + const int textHeight = effectiveLineHeight() + line->getRubyShift(renderer.getFontAscenderSize(fontId)); + int lineHeight = textHeight; + for (const auto& image : lineImages) lineHeight = std::max(lineHeight, static_cast(image.height)); if (!currentPage) { if (!startNewPage("line layout")) { @@ -3977,7 +4035,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line, const } // Track cumulative words to retire links after laying out their final word. - wordsExtractedInBlock += line->wordCount(); + wordsExtractedInBlock += line->wordCount() + lineImages.size(); auto footnoteIt = pendingFootnotes.begin(); while (footnoteIt != pendingFootnotes.end() && footnoteIt->first <= wordsExtractedInBlock) { currentPage->addFootnote(footnoteIt->second.number, footnoteIt->second.href, footnoteIt->second.linkId); @@ -3988,13 +4046,35 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line, const // Apply horizontal left inset (margin + padding) as x position offset const int16_t xOffset = line->getBlockStyle().leftInset(); - auto pageLine = makeUniqueNoThrow(line, xOffset, currentPageNextY); - if (!pageLine) { - LOG_ERR("EHP", "Failed to create PageLine"); - lowMemoryAbort = true; - return; + if (!line->isEmpty()) { + // A taller inline image raises the line's top edge; keep the text beside + // its lower edge instead of leaving it stranded above the image. + auto pageLine = makeUniqueNoThrow(line, xOffset, currentPageNextY + lineHeight - textHeight); + if (!pageLine) { + LOG_ERR("EHP", "Failed to create PageLine"); + lowMemoryAbort = true; + return; + } + currentPage->elements.push_back(std::move(pageLine)); + } + for (const auto& image : lineImages) { + const auto it = std::find_if(pendingInlineImages.begin(), pendingInlineImages.end(), + [&image](const PendingInlineImage& pending) { return pending.id == image.id; }); + if (it == pendingInlineImages.end()) { + LOG_ERR("EHP", "Missing inline image %u", image.id); + lowMemoryAbort = true; + return; + } + auto pageImage = makeUniqueNoThrow(std::move(it->block), xOffset + image.x, + currentPageNextY + lineHeight - image.height, true); + if (!pageImage) { + LOG_ERR("EHP", "Failed to create inline PageImage"); + lowMemoryAbort = true; + return; + } + currentPage->elements.push_back(std::move(pageImage)); + pendingInlineImages.erase(it); } - currentPage->elements.push_back(std::move(pageLine)); markCurrentPageFromCurrentTextBlock(); currentPageNextY += lineHeight; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 20f74e2fd4c..5e0c32bc7cd 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -36,6 +36,7 @@ class ChapterHtmlSlimParser { static constexpr uint16_t MAX_SIMPLE_TABLE_CELL_WORDS = 160; static constexpr uint8_t TABLE_CELL_PADDING = 6; static constexpr size_t MAX_INLINE_STYLE_DEPTH = 64; + static constexpr size_t MAX_PENDING_INLINE_IMAGES = 16; static constexpr size_t MAX_BLOCK_STYLE_DEPTH = 16; Epub* epub; @@ -96,6 +97,12 @@ class ChapterHtmlSlimParser { std::string contentBase; std::string imageBasePath; int imageCounter = 0; + struct PendingInlineImage { + uint16_t id; + std::unique_ptr block; + }; + std::vector pendingInlineImages; + uint16_t nextInlineImageId = 1; bool lowMemoryImageFallback = false; bool lowMemoryAbort = false; bool attemptedTextLayoutFontCacheRelease = false; @@ -257,7 +264,7 @@ class ChapterHtmlSlimParser { void attachPendingPublisherPageMarkers(int yPos); void flushPartWordBuffer(); void queueInlinePadding(const CssStyle& cssStyle); - void flushLongTextRunIfNeeded(bool force = false); + void flushLongTextRunIfNeeded(bool force = false, bool flushLastLine = false); size_t bufferedWordsBeforeLayoutLimit() const; uint16_t textRunBytesBeforeLayoutLimit() const; void markCurrentPageFromCurrentTextBlock(); diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 31dcaf52a7b..15fd86b0021 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -12,6 +12,25 @@ #include "Epub/BookMetadataCache.h" namespace { +// Expat expands namespace prefixes to URIs, so optimizer-generated prefixes +// such as ns0 are equivalent to opf or the default OPF namespace. +constexpr char OPF_NAMESPACE[] = "http://www.idpf.org/2007/opf|"; +constexpr char DC_NAMESPACE[] = "http://purl.org/dc/elements/1.1/|"; + +bool isOpfElement(const char* name, const char* localName) { + constexpr size_t prefixLength = sizeof(OPF_NAMESPACE) - 1; + if (strncmp(name, OPF_NAMESPACE, prefixLength) == 0) { + return strcmp(name + prefixLength, localName) == 0; + } + // Keep support for older packages without a declared OPF namespace. + return strcmp(name, localName) == 0; +} + +bool isDcElement(const char* name, const char* localName) { + constexpr size_t prefixLength = sizeof(DC_NAMESPACE) - 1; + return strncmp(name, DC_NAMESPACE, prefixLength) == 0 && strcmp(name + prefixLength, localName) == 0; +} + constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; constexpr char MEDIA_TYPE_CSS[] = "text/css"; constexpr char MEDIA_TYPE_IMAGE_PREFIX[] = "image/"; @@ -197,7 +216,7 @@ bool ContentOpfParser::setup() { return false; } - parser = XML_ParserCreate(nullptr); + parser = XML_ParserCreateNS(nullptr, '|'); if (!parser) { LOG_DBG("COF", "Couldn't allocate memory for parser"); lowMemoryFailure = true; @@ -287,23 +306,22 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name // but a malformed package that jumps straight to the manifest must not open // the item-cache file just to have this same check discard it a level down. if (self->metadataOnly && - (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0 || strcmp(name, "spine") == 0 || - strcmp(name, "opf:spine") == 0 || strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) { + (isOpfElement(name, "manifest") || isOpfElement(name, "spine") || isOpfElement(name, "guide"))) { self->metadataComplete = true; return; } - if (self->state == START && (strcmp(name, "package") == 0 || strcmp(name, "opf:package") == 0)) { + if (self->state == START && isOpfElement(name, "package")) { self->state = IN_PACKAGE; return; } - if (self->state == IN_PACKAGE && (strcmp(name, "metadata") == 0 || strcmp(name, "opf:metadata") == 0)) { + if (self->state == IN_PACKAGE && isOpfElement(name, "metadata")) { self->state = IN_METADATA; return; } - if (self->state == IN_METADATA && strcmp(name, "dc:title") == 0) { + if (self->state == IN_METADATA && isDcElement(name, "title")) { // Only capture the first dc:title element; subsequent ones are subtitles if (self->title.empty()) { self->state = IN_BOOK_TITLE; @@ -312,20 +330,20 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } - if (self->state == IN_METADATA && strcmp(name, "dc:creator") == 0) { + if (self->state == IN_METADATA && isDcElement(name, "creator")) { self->state = IN_BOOK_AUTHOR; self->metadataSpacePending = false; self->authorSeparatorPending = !self->author.empty(); return; } - if (self->state == IN_METADATA && strcmp(name, "dc:language") == 0) { + if (self->state == IN_METADATA && isDcElement(name, "language")) { self->state = IN_BOOK_LANGUAGE; self->metadataSpacePending = false; return; } - if (self->state == IN_METADATA && strcmp(name, "dc:subject") == 0) { + if (self->state == IN_METADATA && isDcElement(name, "subject")) { // Subjects are free-form tags; keep the first one as the Library genre. if (self->subject.empty()) { self->state = IN_BOOK_SUBJECT; @@ -334,7 +352,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } - if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { + if (self->state == IN_PACKAGE && isOpfElement(name, "manifest")) { self->state = IN_MANIFEST; if (!Storage.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { LOG_ERR("COF", "Couldn't open temp items file for writing. This is probably going to be a fatal error."); @@ -342,7 +360,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } - if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { + if (self->state == IN_PACKAGE && isOpfElement(name, "spine")) { self->state = IN_SPINE; if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error."); @@ -355,7 +373,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } - if (self->state == IN_PACKAGE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) { + if (self->state == IN_PACKAGE && isOpfElement(name, "guide")) { self->state = IN_GUIDE; // TODO Remove print if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { @@ -364,7 +382,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } - if (self->state == IN_METADATA && (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) { + if (self->state == IN_METADATA && isOpfElement(name, "meta")) { bool isCover = false; bool isSeries = false; bool isSeriesIndex = false; @@ -433,7 +451,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } - if (self->state == IN_MANIFEST && (strcmp(name, "item") == 0 || strcmp(name, "opf:item") == 0)) { + if (self->state == IN_MANIFEST && isOpfElement(name, "item")) { std::string itemId; std::string href; std::string mediaType; @@ -520,7 +538,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name // NOTE: This relies on spine appearing after item manifest (which is pretty safe as it's part of the EPUB spec) // Only run the spine parsing if there's a cache to add it to if (self->cache) { - if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) { + if (self->state == IN_SPINE && isOpfElement(name, "itemref")) { for (int i = 0; atts[i]; i += 2) { if (strcmp(atts[i], "idref") == 0) { const std::string idref = atts[i + 1]; @@ -534,7 +552,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name } } // parse the guide - if (self->state == IN_GUIDE && (strcmp(name, "reference") == 0 || strcmp(name, "opf:reference") == 0)) { + if (self->state == IN_GUIDE && isOpfElement(name, "reference")) { std::string type; std::string guideHref; for (int i = 0; atts[i]; i += 2) { @@ -611,50 +629,50 @@ void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) return; } - if (self->state == IN_SPINE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { + if (self->state == IN_SPINE && isOpfElement(name, "spine")) { self->state = IN_PACKAGE; self->tempItemStore.close(); return; } - if (self->state == IN_GUIDE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) { + if (self->state == IN_GUIDE && isOpfElement(name, "guide")) { self->state = IN_PACKAGE; self->tempItemStore.close(); return; } - if (self->state == IN_MANIFEST && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { + if (self->state == IN_MANIFEST && isOpfElement(name, "manifest")) { self->state = IN_PACKAGE; self->tempItemStore.close(); return; } - if (self->state == IN_BOOK_TITLE && strcmp(name, "dc:title") == 0) { + if (self->state == IN_BOOK_TITLE && isDcElement(name, "title")) { self->state = IN_METADATA; return; } - if (self->state == IN_BOOK_AUTHOR && strcmp(name, "dc:creator") == 0) { + if (self->state == IN_BOOK_AUTHOR && isDcElement(name, "creator")) { self->state = IN_METADATA; return; } - if (self->state == IN_BOOK_LANGUAGE && strcmp(name, "dc:language") == 0) { + if (self->state == IN_BOOK_LANGUAGE && isDcElement(name, "language")) { self->state = IN_METADATA; return; } - if (self->state == IN_BOOK_SUBJECT && strcmp(name, "dc:subject") == 0) { + if (self->state == IN_BOOK_SUBJECT && isDcElement(name, "subject")) { self->state = IN_METADATA; return; } if ((self->state == IN_BOOK_COLLECTION || self->state == IN_BOOK_COLLECTION_TYPE || self->state == IN_BOOK_COLLECTION_POSITION) && - (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) { + isOpfElement(name, "meta")) { self->state = IN_METADATA; return; } - if (self->state == IN_METADATA && (strcmp(name, "metadata") == 0 || strcmp(name, "opf:metadata") == 0)) { + if (self->state == IN_METADATA && isOpfElement(name, "metadata")) { if (self->series.empty() && self->collectionType == "series") { self->series = std::move(self->collectionName); self->seriesIndex = std::move(self->collectionPosition); @@ -666,7 +684,7 @@ void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) return; } - if (self->state == IN_PACKAGE && (strcmp(name, "package") == 0 || strcmp(name, "opf:package") == 0)) { + if (self->state == IN_PACKAGE && isOpfElement(name, "package")) { self->state = START; return; } diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 08a92b265dc..424d487ec7d 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1070,6 +1070,26 @@ int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontF return w; } +GfxRenderer::TextVerticalBounds GfxRenderer::getTextVerticalBounds(const int fontId, const char* text) const { +#if CROSSINK_SCALABLE_FONTS + ScalableFontAccess access; +#endif + if (!text || !*text) return {}; + const int resolvedFontId = resolveTextFontId(fontId, text, EpdFontFamily::REGULAR); + const auto fontIt = fontMap.find(resolvedFontId); + if (fontIt == fontMap.end()) { + LOG_ERR("GFX", "Font %d not found", resolvedFontId); + return {}; + } + + std::string visualBuffer; + const char* textCursor = resolveVisualText(text, visualBuffer, BidiUtils::BidiBaseDir::AUTO); + int width = 0, height = 0, minY = 0, maxY = 0; + fontIt->second.getTextDimensions(textCursor, &width, &height, EpdFontFamily::REGULAR, &minY, &maxY); + const int ascender = fontIt->second.getData(EpdFontFamily::REGULAR)->ascender; + return {ascender - maxY, ascender - minY}; +} + void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* text, const bool black, const EpdFontFamily::Style style, const BidiUtils::BidiBaseDir baseDir) const { const int x = (getScreenWidth() - getTextWidth(fontId, text, style, baseDir)) / 2; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 1bcf2c54af8..f9e6a101764 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -292,6 +292,12 @@ class GfxRenderer { void writeFramebufferRegion(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const uint8_t* src); // Text + struct TextVerticalBounds { + int top = 0; + int bottom = 0; + }; + // Visible regular-text glyph bounds relative to the y coordinate passed to drawText(). + TextVerticalBounds getTextVerticalBounds(int fontId, const char* text) const; int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR, BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const; void drawCenteredText(int fontId, int y, const char* text, bool black = true, diff --git a/lib/I18n/translations/arabic.yaml b/lib/I18n/translations/arabic.yaml index 6c4f8913daf..ff0963bde0b 100644 --- a/lib/I18n/translations/arabic.yaml +++ b/lib/I18n/translations/arabic.yaml @@ -484,6 +484,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "صفحة %d من %d، %.2f%% إجمالا" STR_DEVICE_FROM_FORMAT: " من: %s" STR_APPLY_REMOTE: "استخدام تقدم الخادم" STR_UPLOAD_LOCAL: "رفع التقدم المحلي" +STR_SYNC_REMOTE_TITLE: "عن بُعد" +STR_SYNC_LOCAL_TITLE: "هذا الجهاز" +STR_SYNC_AHEAD: "متقدم" +STR_SYNC_PAGE_FORMAT: "صفحة %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "صفحة %d من %d" STR_NO_REMOTE_MSG: "لا يوجد تقدم على الخادم" STR_UPLOAD_PROMPT: "رفع الموضع الحالي؟" STR_UPLOAD_SUCCESS: "تم رفع التقدم!" diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index 37570ed771f..357e2101e8b 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -338,6 +338,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Старонка %d/%d, %.2f%% усяго" STR_DEVICE_FROM_FORMAT: "Ад: %s" STR_APPLY_REMOTE: "Прымяніць аддалены прагрэс" STR_UPLOAD_LOCAL: "Адправіць лакальны прагрэс" +STR_SYNC_REMOTE_TITLE: "Аддалена" +STR_SYNC_LOCAL_TITLE: "Гэта прылада" +STR_SYNC_AHEAD: "Наперадзе" +STR_SYNC_PAGE_FORMAT: "Старонка %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Старонка %d з %d" STR_NO_REMOTE_MSG: "Аддалены прагрэс не знойдзены" STR_UPLOAD_PROMPT: "Адправіць бягучую пазіцыю?" STR_UPLOAD_SUCCESS: "Прагрэс адпраўлены!" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index fe87800ba0b..eb98ace1a12 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -391,6 +391,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Pàgina %d/%d, %.2f%% total" STR_DEVICE_FROM_FORMAT: " De: %s" STR_APPLY_REMOTE: "Aplica el progrés remot" STR_UPLOAD_LOCAL: "Puja el progrés local" +STR_SYNC_REMOTE_TITLE: "Remot" +STR_SYNC_LOCAL_TITLE: "Aquest dispositiu" +STR_SYNC_AHEAD: "Avançat" +STR_SYNC_PAGE_FORMAT: "Pàgina %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Pàgina %d de %d" STR_NO_REMOTE_MSG: "No s'ha trobat progrés remot" STR_UPLOAD_PROMPT: "Voleu pujar la posició actual?" STR_UPLOAD_SUCCESS: "Progrés pujat!" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 4a7adaae15e..161783b9486 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -359,6 +359,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Stránka %d/%d, celkově %.2f%%" STR_DEVICE_FROM_FORMAT: " Od: %s" STR_APPLY_REMOTE: "Použít vzdálený průběh" STR_UPLOAD_LOCAL: "Nahrát lokální průběh" +STR_SYNC_REMOTE_TITLE: "Vzdálené" +STR_SYNC_LOCAL_TITLE: "Toto zařízení" +STR_SYNC_AHEAD: "Napřed" +STR_SYNC_PAGE_FORMAT: "Strana %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Strana %d z %d" STR_NO_REMOTE_MSG: "Nenalezen žádný vzdálený průběh" STR_UPLOAD_PROMPT: "Nahrát aktuální pozici?" STR_UPLOAD_SUCCESS: "Průběh nahrán!" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index cfefc87d494..63e663d24b7 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -388,6 +388,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Side %d/%d, %.2f%% samlet" STR_DEVICE_FROM_FORMAT: " Fra: %s" STR_APPLY_REMOTE: "Anvend fjernfremskridt" STR_UPLOAD_LOCAL: "Upload lokalt fremskridt" +STR_SYNC_REMOTE_TITLE: "Fjern" +STR_SYNC_LOCAL_TITLE: "Denne enhed" +STR_SYNC_AHEAD: "Foran" +STR_SYNC_PAGE_FORMAT: "Side %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Side %d af %d" STR_NO_REMOTE_MSG: "Ingen fjernfremskridt fundet" STR_UPLOAD_PROMPT: "Upload nuværende position?" STR_UPLOAD_SUCCESS: "Fremskridt uploadet!" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 653d0045d4c..6163f342ee3 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -387,6 +387,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Pagina %d/%d, %.2f%% totaal" STR_DEVICE_FROM_FORMAT: " Van: %s" STR_APPLY_REMOTE: "Externe voortgang toepassen" STR_UPLOAD_LOCAL: "Lokale voortgang uploaden" +STR_SYNC_REMOTE_TITLE: "Extern" +STR_SYNC_LOCAL_TITLE: "Dit apparaat" +STR_SYNC_AHEAD: "Verder" +STR_SYNC_PAGE_FORMAT: "Pagina %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Pagina %d van %d" STR_NO_REMOTE_MSG: "Geen externe voortgang gevonden" STR_UPLOAD_PROMPT: "Huidige positie uploaden?" STR_UPLOAD_SUCCESS: "Voortgang geüpload!" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index fb2367a58ff..d215c3bcba3 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -621,6 +621,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Page %d/%d, %.2f%% overall" STR_DEVICE_FROM_FORMAT: " From: %s" STR_APPLY_REMOTE: "Apply remote progress" STR_UPLOAD_LOCAL: "Upload local progress" +STR_SYNC_REMOTE_TITLE: "Remote" +STR_SYNC_LOCAL_TITLE: "This device" +STR_SYNC_AHEAD: "Ahead" +STR_SYNC_PAGE_FORMAT: "Page %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Page %d of %d" STR_NO_REMOTE_MSG: "No remote progress found" STR_UPLOAD_PROMPT: "Upload current position?" STR_UPLOAD_SUCCESS: "Progress uploaded!" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index f526c3530ce..c72e72e0510 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -356,6 +356,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Sivu %d/%d, %.2f%% kokonaisuudesta" STR_DEVICE_FROM_FORMAT: " Lähde: %s" STR_APPLY_REMOTE: "Käytä etäedistymistä" STR_UPLOAD_LOCAL: "Lähetä paikallinen edistyminen" +STR_SYNC_REMOTE_TITLE: "Etä" +STR_SYNC_LOCAL_TITLE: "Tämä laite" +STR_SYNC_AHEAD: "Edellä" +STR_SYNC_PAGE_FORMAT: "Sivu %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Sivu %d/%d" STR_NO_REMOTE_MSG: "Etäedistymistä ei löytynyt" STR_UPLOAD_PROMPT: "Lähetä nykyinen sijainti?" STR_UPLOAD_SUCCESS: "Edistyminen lähetetty!" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index ba8dbaf4e14..1bfa33374f9 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -390,6 +390,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Page %d/%d, %.2f%% total" STR_DEVICE_FROM_FORMAT: " De : %s" STR_APPLY_REMOTE: "Appliquer progression en ligne" STR_UPLOAD_LOCAL: "Envoyer progression locale" +STR_SYNC_REMOTE_TITLE: "Distant" +STR_SYNC_LOCAL_TITLE: "Cet appareil" +STR_SYNC_AHEAD: "En avance" +STR_SYNC_PAGE_FORMAT: "Page %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Page %d sur %d" STR_NO_REMOTE_MSG: "Aucune progression en ligne" STR_UPLOAD_PROMPT: "Envoyer position actuelle ?" STR_UPLOAD_SUCCESS: "Progression envoyée !" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 9ae40f41263..313b446d245 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -482,6 +482,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Seite %d/%d, %.2f %%% Gesamtfortschritt" STR_DEVICE_FROM_FORMAT: " Von: %s" STR_APPLY_REMOTE: "Entfernten Fortschritt übernehmen" STR_UPLOAD_LOCAL: "Lokalen Fortschritt hochladen" +STR_SYNC_REMOTE_TITLE: "Remote" +STR_SYNC_LOCAL_TITLE: "Dieses Gerät" +STR_SYNC_AHEAD: "Weiter" +STR_SYNC_PAGE_FORMAT: "Seite %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Seite %d von %d" STR_NO_REMOTE_MSG: "Keinen entfernten Fortschritt gefunden" STR_UPLOAD_PROMPT: "Aktuelle Position hochladen?" STR_UPLOAD_SUCCESS: "Fortschritt hochgeladen!" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index 1ab8664b613..228c0544d83 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -371,6 +371,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "דף %d מתוך %d, סה״כ %.2f%%" STR_DEVICE_FROM_FORMAT: " ממכשיר: %s" STR_APPLY_REMOTE: "השתמש בהתקדמות מהשרת" STR_UPLOAD_LOCAL: "העלה התקדמות מהמכשיר לשרת" +STR_SYNC_REMOTE_TITLE: "מרוחק" +STR_SYNC_LOCAL_TITLE: "מכשיר זה" +STR_SYNC_AHEAD: "מתקדם יותר" +STR_SYNC_PAGE_FORMAT: "עמוד %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "עמוד %d מתוך %d" STR_NO_REMOTE_MSG: "לא נמצאה התקדמות בשרת" STR_UPLOAD_PROMPT: "להעלות את המיקום הנוכחי לשרת?" STR_UPLOAD_SUCCESS: "ההתקדמות הועלתה בהצלחה!" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 9de0bf8dfb4..50c1cb2d904 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -387,6 +387,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "%d/%d. oldal, %.2f%% összesen" STR_DEVICE_FROM_FORMAT: " Forrás: %s" STR_APPLY_REMOTE: "Távoli haladás alkalmazása" STR_UPLOAD_LOCAL: "Helyi haladás feltöltése" +STR_SYNC_REMOTE_TITLE: "Távoli" +STR_SYNC_LOCAL_TITLE: "Ez az eszköz" +STR_SYNC_AHEAD: "Előrébb" +STR_SYNC_PAGE_FORMAT: "%d. oldal" +STR_SYNC_PAGE_TOTAL_FORMAT: "%d/%d. oldal" STR_NO_REMOTE_MSG: "Nem található távoli haladás" STR_UPLOAD_PROMPT: "Jelenlegi pozíció feltöltése?" STR_UPLOAD_SUCCESS: "Haladás feltöltve!" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 2ba61f8677a..7cacafbd2b2 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -398,6 +398,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Pagina %d/%d, %.2f%% totale" STR_DEVICE_FROM_FORMAT: " Da: %s" STR_APPLY_REMOTE: "Applica posizione remota" STR_UPLOAD_LOCAL: "Invia posizione locale" +STR_SYNC_REMOTE_TITLE: "Remota" +STR_SYNC_LOCAL_TITLE: "Questo dispositivo" +STR_SYNC_AHEAD: "Più avanti" +STR_SYNC_PAGE_FORMAT: "Pagina %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Pagina %d di %d" STR_NO_REMOTE_MSG: "Nessuna posizione remota trovata" STR_UPLOAD_PROMPT: "Inviare la posizione attuale?" STR_UPLOAD_SUCCESS: "Posizione inviata!" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 7cf65a511d5..a1adbf5bb60 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -352,6 +352,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "%d/%d-бет, жалпы %.2f%%" STR_DEVICE_FROM_FORMAT: " Бастап: %s" STR_APPLY_REMOTE: "Қашықтағы үлгерімді қолдану" STR_UPLOAD_LOCAL: "Жергілікті үлгерімді жүктеп салу" +STR_SYNC_REMOTE_TITLE: "Қашықтағы" +STR_SYNC_LOCAL_TITLE: "Осы құрылғы" +STR_SYNC_AHEAD: "Алда" +STR_SYNC_PAGE_FORMAT: "%d-бет" +STR_SYNC_PAGE_TOTAL_FORMAT: "%d/%d-бет" STR_NO_REMOTE_MSG: "Қашықтағы үлгерім табылмады" STR_UPLOAD_PROMPT: "Ағымдағы орынды жүктеп салу керек пе?" STR_UPLOAD_SUCCESS: "Үлгерім жүктеп салынды!" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 84894557895..11b943298a4 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -385,6 +385,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Psl %d/%d, %.2f%% bendrai" STR_DEVICE_FROM_FORMAT: " Iš: %s" STR_APPLY_REMOTE: "Taikyti nuotolinį" STR_UPLOAD_LOCAL: "Įkelti vietinį" +STR_SYNC_REMOTE_TITLE: "Nuotolinis" +STR_SYNC_LOCAL_TITLE: "Šis įrenginys" +STR_SYNC_AHEAD: "Toliau" +STR_SYNC_PAGE_FORMAT: "Puslapis %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Puslapis %d iš %d" STR_NO_REMOTE_MSG: "Progresų nerasta" STR_UPLOAD_PROMPT: "Įkelti poziciją?" STR_UPLOAD_SUCCESS: "Įkelta!" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index a89ba015b3c..30caf51267e 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -388,6 +388,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Strona %d/%d, %.2f%% całości" STR_DEVICE_FROM_FORMAT: " Od: %s" STR_APPLY_REMOTE: "Zastosuj zdalny postęp" STR_UPLOAD_LOCAL: "Prześlij lokalny postęp" +STR_SYNC_REMOTE_TITLE: "Zdalny" +STR_SYNC_LOCAL_TITLE: "To urządzenie" +STR_SYNC_AHEAD: "Dalej" +STR_SYNC_PAGE_FORMAT: "Strona %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Strona %d z %d" STR_NO_REMOTE_MSG: "Nie znaleziono zdalnego postępu" STR_UPLOAD_PROMPT: "Wysłać obecną pozycję?" STR_UPLOAD_SUCCESS: "Postęp wysłany!" diff --git a/lib/I18n/translations/portuguese-BR.yaml b/lib/I18n/translations/portuguese-BR.yaml index 876ee63ffc7..12e43bf2e9d 100644 --- a/lib/I18n/translations/portuguese-BR.yaml +++ b/lib/I18n/translations/portuguese-BR.yaml @@ -478,6 +478,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Página %d/%d, %.2f%% total" STR_DEVICE_FROM_FORMAT: " De: %s" STR_APPLY_REMOTE: "Aplicar progresso remoto" STR_UPLOAD_LOCAL: "Enviar progresso local" +STR_SYNC_REMOTE_TITLE: "Remoto" +STR_SYNC_LOCAL_TITLE: "Este dispositivo" +STR_SYNC_AHEAD: "À frente" +STR_SYNC_PAGE_FORMAT: "Página %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Página %d de %d" STR_NO_REMOTE_MSG: "Nenhum progresso remoto encontrado" STR_UPLOAD_PROMPT: "Enviar posição atual?" STR_UPLOAD_SUCCESS: "Progresso enviado!" diff --git a/lib/I18n/translations/portuguese-PT.yaml b/lib/I18n/translations/portuguese-PT.yaml index edbb666864a..64df3e8acae 100644 --- a/lib/I18n/translations/portuguese-PT.yaml +++ b/lib/I18n/translations/portuguese-PT.yaml @@ -572,6 +572,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Página %d/%d, %.2f%% no total" STR_DEVICE_FROM_FORMAT: " De: %s" STR_APPLY_REMOTE: "Aplicar progresso remoto" STR_UPLOAD_LOCAL: "Enviar progresso local" +STR_SYNC_REMOTE_TITLE: "Remoto" +STR_SYNC_LOCAL_TITLE: "Este dispositivo" +STR_SYNC_AHEAD: "À frente" +STR_SYNC_PAGE_FORMAT: "Página %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Página %d de %d" STR_NO_REMOTE_MSG: "Nenhum progresso remoto encontrado" STR_UPLOAD_PROMPT: "Enviar a posição atual?" STR_UPLOAD_SUCCESS: "Progresso enviado!" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 88a32ff4833..1414f2c66cf 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -387,6 +387,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Pagina %d/%d, %.2f%% din total" STR_DEVICE_FROM_FORMAT: " De la: %s" STR_APPLY_REMOTE: "Aplică progresul remote" STR_UPLOAD_LOCAL: "Încărcaţi progresul local" +STR_SYNC_REMOTE_TITLE: "La distanță" +STR_SYNC_LOCAL_TITLE: "Acest dispozitiv" +STR_SYNC_AHEAD: "Înainte" +STR_SYNC_PAGE_FORMAT: "Pagina %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Pagina %d din %d" STR_NO_REMOTE_MSG: "Niciun progres remote găsit" STR_UPLOAD_PROMPT: "Încărcaţi poziţia curentă?" STR_UPLOAD_SUCCESS: "Progres încărcat!" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 5a79ee9cab2..9029977d4bd 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -418,6 +418,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Страница %d/%d" STR_DEVICE_FROM_FORMAT: "От: %s" STR_APPLY_REMOTE: "Применить удалённый прогресс" STR_UPLOAD_LOCAL: "Отправить локальный прогресс" +STR_SYNC_REMOTE_TITLE: "Удалённый" +STR_SYNC_LOCAL_TITLE: "Это устройство" +STR_SYNC_AHEAD: "Впереди" +STR_SYNC_PAGE_FORMAT: "Страница %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Страница %d из %d" STR_NO_REMOTE_MSG: "Удалённый прогресс не найден" STR_UPLOAD_PROMPT: "Отправить текущую позицию?" STR_UPLOAD_SUCCESS: "Прогресс отправлен!" diff --git a/lib/I18n/translations/slovak.yaml b/lib/I18n/translations/slovak.yaml index bc8453a1992..426dbafe178 100644 --- a/lib/I18n/translations/slovak.yaml +++ b/lib/I18n/translations/slovak.yaml @@ -406,6 +406,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Strana %d/%d, celkovo %.2f%%" STR_DEVICE_FROM_FORMAT: " Zo zariadenia: %s" STR_APPLY_REMOTE: "Použiť vzdialený priebeh" STR_UPLOAD_LOCAL: "Nahrať lokálny priebeh" +STR_SYNC_REMOTE_TITLE: "Vzdialené" +STR_SYNC_LOCAL_TITLE: "Toto zariadenie" +STR_SYNC_AHEAD: "Vpredu" +STR_SYNC_PAGE_FORMAT: "Strana %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Strana %d z %d" STR_NO_REMOTE_MSG: "Nenašiel sa žiadny vzdialený priebeh" STR_UPLOAD_PROMPT: "Nahrať aktuálnu pozíciu?" STR_UPLOAD_SUCCESS: "Priebeh nahraný!" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index c77d23c0e2c..233aafa6eff 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -385,6 +385,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Stran %d/%d, %.2f%% skupno" STR_DEVICE_FROM_FORMAT: " Iz: %s" STR_APPLY_REMOTE: "Uporabi oddaljen napredek" STR_UPLOAD_LOCAL: "Naloži lokalni napredek" +STR_SYNC_REMOTE_TITLE: "Oddaljeno" +STR_SYNC_LOCAL_TITLE: "Ta naprava" +STR_SYNC_AHEAD: "Naprej" +STR_SYNC_PAGE_FORMAT: "Stran %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Stran %d od %d" STR_NO_REMOTE_MSG: "Oddaljen napredek ni bil najden" STR_UPLOAD_PROMPT: "Naložim trenutno pozicijo?" STR_UPLOAD_SUCCESS: "Napredek naložen!" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 5300b253c72..39603f4b3dd 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -479,6 +479,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Página %d / %d, %.2f%% completado" STR_DEVICE_FROM_FORMAT: " De: %s" STR_APPLY_REMOTE: "Aplicar progreso remoto" STR_UPLOAD_LOCAL: "Subir progreso local" +STR_SYNC_REMOTE_TITLE: "Remoto" +STR_SYNC_LOCAL_TITLE: "Este dispositivo" +STR_SYNC_AHEAD: "Adelantado" +STR_SYNC_PAGE_FORMAT: "Página %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Página %d de %d" STR_NO_REMOTE_MSG: "No se encontró progreso remoto" STR_UPLOAD_PROMPT: "¿Subir posición actual?" STR_UPLOAD_SUCCESS: "¡Progreso subido!" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index ec666948f1f..5de121860e0 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -412,6 +412,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Sida %d/%d, %.2f%% totalt" STR_DEVICE_FROM_FORMAT: " Från: %s" STR_APPLY_REMOTE: "Använd fjärrframsteg" STR_UPLOAD_LOCAL: "Ladda upp lokala framsteg" +STR_SYNC_REMOTE_TITLE: "Fjärr" +STR_SYNC_LOCAL_TITLE: "Den här enheten" +STR_SYNC_AHEAD: "Längre fram" +STR_SYNC_PAGE_FORMAT: "Sida %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Sida %d av %d" STR_NO_REMOTE_MSG: "Inga fjärrframsteg funna" STR_UPLOAD_PROMPT: "Ladda upp nuvarande position?" STR_UPLOAD_SUCCESS: "Framsteg uppladdade!" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index dd57ae42984..5c45c3d89dd 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -359,6 +359,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Sayfa %d/%d, genel %.2f%%" STR_DEVICE_FROM_FORMAT: " Şuradan: %s" STR_APPLY_REMOTE: "Uzak ilerlemeyi uygula" STR_UPLOAD_LOCAL: "Yerel ilerlemeyi yükle" +STR_SYNC_REMOTE_TITLE: "Uzak" +STR_SYNC_LOCAL_TITLE: "Bu cihaz" +STR_SYNC_AHEAD: "İleride" +STR_SYNC_PAGE_FORMAT: "Sayfa %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Sayfa %d/%d" STR_NO_REMOTE_MSG: "Uzak ilerleme bulunamadı" STR_UPLOAD_PROMPT: "Mevcut konumu yükle?" STR_UPLOAD_SUCCESS: "İlerleme yüklendi!" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 640c539fb94..8ae57a5a3a5 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -397,6 +397,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Сторінка %d/%d, %.2f%% загалом" STR_DEVICE_FROM_FORMAT: " Від: %s" STR_APPLY_REMOTE: "Застосувати віддалений прогрес" STR_UPLOAD_LOCAL: "Завантажити локальний прогрес" +STR_SYNC_REMOTE_TITLE: "Віддалений" +STR_SYNC_LOCAL_TITLE: "Цей пристрій" +STR_SYNC_AHEAD: "Попереду" +STR_SYNC_PAGE_FORMAT: "Сторінка %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Сторінка %d з %d" STR_NO_REMOTE_MSG: "Віддаленого прогресу не знайдено" STR_UPLOAD_PROMPT: "Завантажити поточну позицію?" STR_UPLOAD_SUCCESS: "Прогрес завантажено!" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index b0d1a48d400..4848acab66b 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -385,6 +385,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Pàgina %d/%d, %.2f%% total" STR_DEVICE_FROM_FORMAT: " De: %s" STR_APPLY_REMOTE: "Aplica el progrés remot" STR_UPLOAD_LOCAL: "Puja el progrés local" +STR_SYNC_REMOTE_TITLE: "Remot" +STR_SYNC_LOCAL_TITLE: "Aquest dispositiu" +STR_SYNC_AHEAD: "Avançat" +STR_SYNC_PAGE_FORMAT: "Pàgina %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Pàgina %d de %d" STR_NO_REMOTE_MSG: "No s'ha trobat progrés remot" STR_UPLOAD_PROMPT: "Voleu pujar la posició actual?" STR_UPLOAD_SUCCESS: "Progrés pujat!" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index 0e46424a52d..22d2c6e5424 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -412,6 +412,11 @@ STR_PAGE_TOTAL_OVERALL_FORMAT: "Trang %d/%d, %.2f%% toàn bộ" STR_DEVICE_FROM_FORMAT: " Từ: %s" STR_APPLY_REMOTE: "Áp dụng tiến độ từ xa" STR_UPLOAD_LOCAL: "Tải lên tiến độ cục bộ" +STR_SYNC_REMOTE_TITLE: "Từ xa" +STR_SYNC_LOCAL_TITLE: "Thiết bị này" +STR_SYNC_AHEAD: "Đi trước" +STR_SYNC_PAGE_FORMAT: "Trang %d" +STR_SYNC_PAGE_TOTAL_FORMAT: "Trang %d/%d" STR_NO_REMOTE_MSG: "Không tìm thấy tiến độ từ xa" STR_UPLOAD_PROMPT: "Tải lên vị trí hiện tại?" STR_UPLOAD_SUCCESS: "Đã tải tiến độ lên!" diff --git a/lib/KOReaderSync/ChapterXPathResolver.cpp b/lib/KOReaderSync/ChapterXPathResolver.cpp index 9f66ac0d1f6..31eeb08db51 100644 --- a/lib/KOReaderSync/ChapterXPathResolver.cpp +++ b/lib/KOReaderSync/ChapterXPathResolver.cpp @@ -345,8 +345,9 @@ class XPathProgressResolver final : public Print { enum class BoundaryMode { Exclusive, Inclusive }; explicit XPathProgressResolver(const size_t targetVisibleChar, - const BoundaryMode boundaryMode = BoundaryMode::Exclusive) - : targetVisibleChar(targetVisibleChar), boundaryMode(boundaryMode) { + const BoundaryMode boundaryMode = BoundaryMode::Exclusive, + const uint8_t minimumPathDepth = 0) + : targetVisibleChar(targetVisibleChar), boundaryMode(boundaryMode), minimumPathDepth(minimumPathDepth) { parser = XML_ParserCreate(nullptr); if (!parser) { LOG_ERR("KOX", "Failed to create XML parser"); @@ -378,6 +379,9 @@ class XPathProgressResolver final : public Print { return parseOk; } + size_t getLastEligibleEnd() const { return lastEligibleEnd; } + size_t getVisibleChars() const { return visibleChars; } + bool hasMatch() const { return !xpath.empty(); } const std::string& getXPath() const { return xpath; } @@ -526,8 +530,11 @@ class XPathProgressResolver final : public Print { const size_t nextVisibleChars = visibleChars + codepointCount; const bool targetInCurrentChunk = boundaryMode == BoundaryMode::Inclusive ? targetVisibleChar <= nextVisibleChars : targetVisibleChar < nextVisibleChars; - if (targetInCurrentChunk) { - const size_t delta = targetVisibleChar - visibleChars; + const bool eligiblePath = path.size() >= minimumPathDepth; + if (eligiblePath) lastEligibleEnd = nextVisibleChars; + if (targetInCurrentChunk && eligiblePath) { + // A target in a split container's whitespace advances to its next child. + const size_t delta = targetVisibleChar > visibleChars ? targetVisibleChar - visibleChars : 0; const int texNode = textNodeIndexStack.empty() ? 0 : textNodeIndexStack.back(); const size_t charOff = visibleChars - textNodeStartChars + delta; xpath = buildParagraphXPath(spineIndex, path, texNode, charOff); @@ -547,6 +554,8 @@ class XPathProgressResolver final : public Print { XML_Parser parser = nullptr; const size_t targetVisibleChar; const BoundaryMode boundaryMode; + const uint8_t minimumPathDepth; + size_t lastEligibleEnd = 0; bool parseOk = true; bool insideBody = false; bool stopped = false; @@ -564,6 +573,24 @@ class XPathProgressResolver final : public Print { std::string xpath; }; +std::string resolveVisibleOffset(const std::shared_ptr& epub, const int spineIndex, const std::string& href, + size_t target, XPathProgressResolver::BoundaryMode boundaryMode, + const uint8_t minimumPathDepth) { + for (int attempt = 0; attempt < 2; ++attempt) { + XPathProgressResolver resolver(target, boundaryMode, minimumPathDepth); + if (!resolver.ok()) return ""; + resolver.spineIndex = spineIndex; + if (!epub->readItemContentsToStream(href, resolver, 1024) || !resolver.finish()) return ""; + if (resolver.hasMatch()) return resolver.getXPath(); + if (minimumPathDepth == 0 || resolver.getLastEligibleEnd() == 0 || target > resolver.getVisibleChars()) return ""; + // Trailing container whitespace has no next child. Re-read at the last + // eligible character, without retaining a path for every parsed text chunk. + target = resolver.getLastEligibleEnd() - 1; + boundaryMode = XPathProgressResolver::BoundaryMode::Exclusive; + } + return ""; +} + std::string findXPathForElement(const std::shared_ptr& epub, const int spineIndex, const uint16_t elementIndex, const char* tagName) { if (!epub || elementIndex == 0 || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) { @@ -606,7 +633,7 @@ std::string ChapterXPathResolver::findXPathForListItem(const std::shared_ptr& epub, const int spineIndex, - const float intraSpineProgress) { + const float intraSpineProgress, const uint8_t minimumPathDepth) { if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) { return ""; } @@ -617,6 +644,7 @@ std::string ChapterXPathResolver::findXPathForProgress(const std::shared_ptr 0.0f)) { + if (minimumPathDepth > 0) return findXPathForVisibleTextOffset(epub, spineIndex, 0, minimumPathDepth); return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; } @@ -634,28 +662,19 @@ std::string ChapterXPathResolver::findXPathForProgress(const std::shared_ptr(1, std::min(totalVisibleChars, static_cast(std::ceil(clamped * totalVisibleChars)))); - XPathProgressResolver resolver(targetVisibleChar, XPathProgressResolver::BoundaryMode::Inclusive); - if (!resolver.ok()) { - return ""; - } - - resolver.spineIndex = spineIndex; - if (!epub->readItemContentsToStream(href, resolver, 1024) || !resolver.finish()) { - return ""; - } - - if (resolver.hasMatch()) { - LOG_DBG("KOX", "Resolved progress %.3f in spine %d -> %s", intraSpineProgress, spineIndex, - resolver.getXPath().c_str()); - return resolver.getXPath(); + const auto xpath = resolveVisibleOffset(epub, spineIndex, href, targetVisibleChar, + XPathProgressResolver::BoundaryMode::Inclusive, minimumPathDepth); + if (xpath.empty()) { + LOG_DBG("KOX", "Could not resolve progress %.3f in spine %d", intraSpineProgress, spineIndex); + } else { + LOG_DBG("KOX", "Resolved progress %.3f in spine %d -> %s", intraSpineProgress, spineIndex, xpath.c_str()); } - - LOG_DBG("KOX", "Could not resolve progress %.3f in spine %d", intraSpineProgress, spineIndex); - return ""; + return xpath; } std::string ChapterXPathResolver::findXPathForVisibleTextOffset(const std::shared_ptr& epub, const int spineIndex, - const uint32_t visibleTextOffset) { + const uint32_t visibleTextOffset, + const uint8_t minimumPathDepth) { if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) { return ""; } @@ -665,18 +684,11 @@ std::string ChapterXPathResolver::findXPathForVisibleTextOffset(const std::share return ""; } - XPathProgressResolver resolver(visibleTextOffset); - if (!resolver.ok()) { - return ""; - } - resolver.spineIndex = spineIndex; - if (!epub->readItemContentsToStream(href, resolver, 1024) || !resolver.finish()) { - return ""; - } - if (!resolver.hasMatch()) { + const auto xpath = resolveVisibleOffset(epub, spineIndex, href, visibleTextOffset, + XPathProgressResolver::BoundaryMode::Exclusive, minimumPathDepth); + if (xpath.empty()) { LOG_DBG("KOX", "Could not resolve visible offset %lu in spine %d", static_cast(visibleTextOffset), spineIndex); - return ""; } - return resolver.getXPath(); + return xpath; } diff --git a/lib/KOReaderSync/ChapterXPathResolver.h b/lib/KOReaderSync/ChapterXPathResolver.h index 6f18700cee9..9676150a7d4 100644 --- a/lib/KOReaderSync/ChapterXPathResolver.h +++ b/lib/KOReaderSync/ChapterXPathResolver.h @@ -33,10 +33,13 @@ class ChapterXPathResolver { * * An empty string means parsing failed or the location could not be resolved. */ - static std::string findXPathForProgress(const std::shared_ptr& epub, int spineIndex, float intraSpineProgress); + static std::string findXPathForProgress(const std::shared_ptr& epub, int spineIndex, float intraSpineProgress, + uint8_t minimumPathDepth = 0); + // minimumPathDepth keeps split-book anchors inside the mapped children, + // selecting the next child (or the last content character at the end). // Resolve a zero-based visible Unicode codepoint offset. This is the stable // page position stored in CrossInk's section cache, independent of layout. static std::string findXPathForVisibleTextOffset(const std::shared_ptr& epub, int spineIndex, - uint32_t visibleTextOffset); + uint32_t visibleTextOffset, uint8_t minimumPathDepth = 0); }; diff --git a/lib/KOReaderSync/ProgressMapper.cpp b/lib/KOReaderSync/ProgressMapper.cpp index c4d18e660ee..db6026734ef 100644 --- a/lib/KOReaderSync/ProgressMapper.cpp +++ b/lib/KOReaderSync/ProgressMapper.cpp @@ -802,8 +802,15 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr& epub, c float intra = (pos.totalPages > 1) ? static_cast(pos.pageNumber) / static_cast(pos.totalPages - 1) : 0.0f; result.percentage = epub->calculateProgress(pos.spineIndex, intra); + uint8_t minimumPathDepth = 0; + Epub::SourceSpineMapEntry sourceEntry; + if (coordinateSpace == PositionCoordinateSpace::SourceDocument && + epub->getSourceSpineMapEntry(pos.spineIndex, sourceEntry) && sourceEntry.rangeCount > 0) { + minimumPathDepth = sourceEntry.containerDepth + 1; + } if (pos.hasVisibleTextOffset) { - result.xpath = ChapterXPathResolver::findXPathForVisibleTextOffset(epub, pos.spineIndex, pos.visibleTextOffset); + result.xpath = ChapterXPathResolver::findXPathForVisibleTextOffset(epub, pos.spineIndex, pos.visibleTextOffset, + minimumPathDepth); } if (result.xpath.empty() && pos.hasLiIndex && pos.liIndex > 0) { result.xpath = ChapterXPathResolver::findXPathForListItem(epub, pos.spineIndex, pos.liIndex); @@ -813,7 +820,7 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr& epub, c } // Fall back to progress-based XPath, then synthetic progress mapping. if (result.xpath.empty()) { - result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra); + result.xpath = ChapterXPathResolver::findXPathForProgress(epub, pos.spineIndex, intra, minimumPathDepth); } if (result.xpath.empty()) { result.xpath = generateXPath(epub, pos.spineIndex, intra); diff --git a/lib/LibraryIndex/LibraryBuilder.cpp b/lib/LibraryIndex/LibraryBuilder.cpp index a09202a791e..6c84332d44e 100644 --- a/lib/LibraryIndex/LibraryBuilder.cpp +++ b/lib/LibraryIndex/LibraryBuilder.cpp @@ -1683,6 +1683,8 @@ void invalidateLibraryIndex() { indexDirty.store(true, std::memory_order_relaxed bool libraryIndexNeedsRefresh() { return indexDirty.load(std::memory_order_relaxed); } +void restoreLibraryIndexAfterSleep() { indexDirty.store(false, std::memory_order_relaxed); } + bool buildLibraryIndex(const char* rootPath, BuildStats& stats, const bool readMetadata, const BuildControl* control, const bool retryFailedMetadata) { // Clear before scanning, not after: a file mutation during the scan must diff --git a/lib/LibraryIndex/LibraryBuilder.h b/lib/LibraryIndex/LibraryBuilder.h index 7cbb8818a5b..ed0eed3125d 100644 --- a/lib/LibraryIndex/LibraryBuilder.h +++ b/lib/LibraryIndex/LibraryBuilder.h @@ -79,11 +79,13 @@ struct BuildControl { bool buildLibraryIndex(const char* rootPath, BuildStats& stats, bool readMetadata = false, const BuildControl* control = nullptr, bool retryFailedMetadata = false); -// Starts dirty on every boot to reconcile external card edits. File-changing +// Starts dirty on cold boots to reconcile external card edits. File-changing // activities must invalidate before returning to Library. A successful scan // clears only changes known when it started; failures remain retryable. void invalidateLibraryIndex(); bool libraryIndexNeedsRefresh(); +// Boot-only: called after validating a clean scan retained across deep sleep. +void restoreLibraryIndexAfterSleep(); // Live index path, shared by the builder and activity. const char* libraryIndexPath(); diff --git a/lib/LibraryIndex/LibraryScanSleepToken.h b/lib/LibraryIndex/LibraryScanSleepToken.h new file mode 100644 index 00000000000..6412fb59e50 --- /dev/null +++ b/lib/LibraryIndex/LibraryScanSleepToken.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace library { +// Kept in RTC memory by main.cpp. No constructor: uninitialized RTC storage +// must be validated before use and consumed even on a reset or cold boot. +struct ScanSleepToken { + uint32_t magic; + uint32_t check; + static constexpr uint32_t MAGIC = 0x4C494233; + + void save(const bool current) { + magic = current ? MAGIC : 0; + check = current ? ~MAGIC : 0; + } + bool consume(const bool deepSleepWake) { + const bool current = deepSleepWake && magic == MAGIC && check == ~MAGIC; + magic = check = 0; + return current; + } +}; +} // namespace library diff --git a/platformio.ini b/platformio.ini index 64e06fc4e94..e56f51d2e63 100644 --- a/platformio.ini +++ b/platformio.ini @@ -3,7 +3,7 @@ default_envs = default, sticky, x4-pro, x4-classic extra_configs = platformio.local.ini [crossink] -version = 1.6.0 +version = 1.6.1 ; UI languages compiled into the firmware, as comma-separated codes from lib/I18n/translations ; (e.g. "en,de,fr"). English is always included. Languages left out are not offered in the @@ -18,6 +18,7 @@ framework = arduino monitor_speed = 115200 upload_speed = 921600 check_tool = cppcheck +; The FreeInk SDK is maintained separately; report defects in CrossInk-owned code here. check_flags = --enable=all --suppress=missingInclude --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr --suppress=*:*freeink-sdk/* --suppress=normalCheckLevelMaxBranches --suppress=checkersReport check_skip_packages = yes diff --git a/scripts/run_simulator_smoke_test.py b/scripts/run_simulator_smoke_test.py index 4d592892b91..b6d69f7555f 100755 --- a/scripts/run_simulator_smoke_test.py +++ b/scripts/run_simulator_smoke_test.py @@ -88,6 +88,8 @@ def run_smoke(args: argparse.Namespace) -> int: env["CROSSINK_SIMULATOR_SMOKE_TEST"] = "1" env["CROSSINK_SIMULATOR_SMOKE_BOOK"] = simulator_book_path env["CROSSINK_SIMULATOR_SMOKE_PAGE_TURNS"] = str(args.page_turns) + if args.home_themes: + env["CROSSINK_SIMULATOR_SMOKE_HOME_THEMES"] = "1" if args.theme: env["CROSSINK_SIMULATOR_SMOKE_THEME"] = str(THEMES[args.theme]) if args.headless: @@ -139,6 +141,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--timeout", type=int, default=45, help="Seconds before the simulator run is treated as hung") parser.add_argument("--page-turns", type=int, default=2, help="Number of EPUB page-forward taps to run") parser.add_argument("--theme", choices=sorted(THEMES), help="UI theme to use during the smoke test") + parser.add_argument("--home-themes", action="store_true", help="Compare drawer theme changes with fresh Home renders (X4 Pro)") parser.add_argument("--no-build", dest="build", action="store_false", help="Run the existing simulator binary") parser.add_argument("--window", dest="headless", action="store_false", help="Show the SDL window instead of using dummy video") parser.set_defaults(build=True, headless=True) diff --git a/scripts/test_web_rename_state.py b/scripts/test_web_rename_state.py new file mode 100644 index 00000000000..c6b375d663b --- /dev/null +++ b/scripts/test_web_rename_state.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Exercise the real simulator HTTP rename route with an isolated SD card. + +Run after: pio run -e simulator -j1 +The fixtures need not contain readable books: rename must preserve their bytes +and migrate valid bookmark/clipping records without parsing book content. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import socket +import struct +import subprocess +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import zlib + + +ROOT = Path(__file__).resolve().parents[1] +TITLE = "Saved book title" +AUTHOR = "Saved author" + + +def packed_string(value: str) -> bytes: + encoded = value.encode("utf-8") + return struct.pack(" bytes: + header = struct.pack(" tuple[list[str], bytes]: + offset = 3 # version, uint16 count + fields = [] + for _ in range(3): + length = struct.unpack_from(" dict[str, bytes]: + return {str(p.relative_to(root)): p.read_bytes() for p in root.rglob("*") if p.is_file()} + + +def store_path(path: str, kind: str, clipping: bool = False) -> Path: + crc = zlib.crc32(path.encode("utf-8")) + directory = "clippings" if clipping else "bookmarks" + return Path(".crosspoint") / directory / f"{kind}_{crc}.bin" + + +def native_hashes(root: Path, paths: list[str]) -> dict[str, int]: + # TXT/XTC cache names use the simulator toolchain's std::hash, which varies + # by standard library. Compile this helper with that native compiler. + source = root / "hash.cpp" + source.write_text( + '#include \n#include \n#include \n' + 'int main(int argc, char** argv) { for (int i = 1; i < argc; ++i) ' + 'std::cout << std::hash{}(argv[i]) << "\\n"; }\n' + ) + executable = root / "hash" + subprocess.run(["g++", "-std=c++20", str(source), "-o", str(executable)], check=True) + values = subprocess.check_output([str(executable), *paths], text=True).splitlines() + return dict(zip(paths, map(int, values), strict=True)) + + +def cache_path(path: str, kind: str, hashes: dict[str, int]) -> Path: + if kind == "epub": + value = 14695981039346656037 + for byte in path.encode("utf-8"): + value = ((value ^ byte) * 1099511628211) & ((1 << 64) - 1) + else: + value = hashes[path] + return Path(".crosspoint") / f"{kind}_{value}" + + +def available_port() -> int: + with socket.socket() as http, socket.socket() as websocket: + http.bind(("127.0.0.1", 0)) + port = http.getsockname()[1] + websocket.bind(("127.0.0.1", port + 1)) + return port + + +def run(program: Path) -> None: + cases = [ + ("/books/Café.epub", "/books/Renamed café.EPUB", "epub"), + ("/books/Pages.xtc", "/books/Renamed pages.xtc", "xtc"), + ("/books/Gray.xtch", "/books/Renamed gray.xtch", "xtc"), + ("/books/Text.txt", "/books/Renamed text.txt", "txt"), + ("/books/Notes.md", "/books/Renamed notes.md", "txt"), + ("/books/Cache conflict.epub", "/books/Occupied cache.epub", "epub"), + ("/books/Bad clippings.epub", "/books/Rejected clippings.epub", "epub"), + ("/books/Recent failure.epub", "/books/Rejected recent.epub", "epub"), + ] + with tempfile.TemporaryDirectory(prefix="crossink-web-rename-") as temporary: + root = Path(temporary) + sd = root / "fs_" + sd.mkdir() + hashes = native_hashes(root, [path for old, new, _ in cases for path in (old, new)]) + recent = [] + for old, _, kind in cases: + book = sd / old.lstrip("/") + book.parent.mkdir(parents=True, exist_ok=True) + book.write_bytes(b"Book contents must survive a rename.\n") + cache = sd / cache_path(old, kind, hashes) + for name in ("progress.bin", "progress.bin.bak", "stats_v5.bin", "stats_v5.bin.bak", + "reader_settings.bin", "reading_stats_off", "dictionary_history.txt", + "thumb.bmp", "sections/3.bin", "images/cover.pxc"): + target = cache / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((old + ":" + name).encode("utf-8")) + for clipping in (False, True) if kind == "epub" else (False,): + target = sd / store_path(old, kind, clipping) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(store_fixture(old, clipping)) + recent.append({"path": old, "title": TITLE, "author": AUTHOR, + "coverBmpPath": "/" + str(cache.relative_to(sd) / "thumb.bmp")}) + + state_path = sd / ".crosspoint/state.json" + pinned_image = "/books/pinned.bmp" + (sd / pinned_image.lstrip("/")).write_bytes(b"Pinned image bytes") + state_path.write_text(json.dumps({"openEpubPath": cases[0][0], + "favoriteSleepImagePath": pinned_image, + "favoriteBootImagePath": pinned_image})) + recent_path = sd / ".crosspoint/recent.json" + recent.append({"path": "/books/Old recent.epub", "title": "Previous book"}) + recent_path.write_text(json.dumps({"books": recent})) + global_stats = sd / ".crosspoint/global_stats.bin" + global_stats.write_bytes(b"Global reading history stays intact.") + port = available_port() + url = f"http://127.0.0.1:{port}" + env = os.environ.copy() + # SimulatorLifecycle restores this existing firmware boot token. Target + # 6 is FILE_TRANSFER; payload 2 selects CREATE_HOTSPOT without UI timing. + env.update(SDL_VIDEODRIVER="dummy", CROSSPOINT_SIM_SD=str(sd), + CROSSPOINT_SIM_HTTP_PORT=str(port), + CROSSPOINT_SIM_SILENT_REBOOT_MAGIC=str(0xC1EAB007), + CROSSPOINT_SIM_SILENT_REBOOT_TARGET="6", CROSSPOINT_SIM_SILENT_REBOOT_PAYLOAD="2") + env.pop("CROSSINK_SIMULATOR_SMOKE_TEST", None) + env.pop("CROSSPOINT_SIM_INPUT_SCRIPT", None) + log_path = root / "simulator.log" + with log_path.open("w") as log: + process = subprocess.Popen([str(program)], cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT) + try: + deadline = time.monotonic() + 45 + while True: + try: + with urllib.request.urlopen(url + "/", timeout=1) as response: + if response.status == 200: + break + except (OSError, urllib.error.URLError): + if process.poll() is not None or time.monotonic() >= deadline: + raise RuntimeError("Simulator web portal did not start") + time.sleep(0.1) + + def rename(old: str, new: str) -> tuple[int, str]: + body = urllib.parse.urlencode({"path": old, "name": Path(new).name}).encode() + request = urllib.request.Request(url + "/rename", data=body) + try: + response = urllib.request.urlopen(request, timeout=10) + except urllib.error.HTTPError as error: + response = error + with response: + return response.code, response.read().decode() + + checks = 0 + + def unchanged_after_rejection(old: str, new: str, status: int) -> None: + nonlocal checks + before = tree_contents(sd) + actual, message = rename(old, new) + assert actual == status, (old, actual, message) + assert tree_contents(sd) == before, f"Rejected rename changed saved files: {old}" + checks += 1 + print(f"PASS: rejected rename preserves all files ({Path(old).name})") + + unchanged_after_rejection(cases[0][0], "/books/Changed.txt", 400) + unchanged_after_rejection(cases[0][0], "/books/Changed.bin", 400) + target = sd / "books/Existing.epub" + target.write_bytes(b"Existing book") + unchanged_after_rejection(cases[0][0], "/books/Existing.epub", 409) + + old, new, kind = cases[5] + conflict = sd / cache_path(new, kind, hashes) + conflict.mkdir() + (conflict / "stats_v5.bin").write_bytes(b"Other saved history") + unchanged_after_rejection(old, new, 409) + + # Orphaned destination state must never attach to an uncached + # source book. Cover cache, saved-item and recovery files. + uncached = "/books/Uncached source.epub" + (sd / uncached.lstrip("/")).write_bytes(b"A different book") + for category in ("cache", "bookmarks", "clippings", "clipping backup", "recent"): + destination = f"/books/Old {category}.epub" + if category == "recent": + unchanged_after_rejection(uncached, destination, 409) + continue + if category == "cache": + stale = sd / cache_path(destination, "epub", hashes) / "stats_v5.bin" + else: + stale = sd / store_path(destination, "epub", category.startswith("clipping")) + if category == "clipping backup": + stale = Path(str(stale) + ".bak") + stale.parent.mkdir(parents=True, exist_ok=True) + stale.write_bytes(b"Previous book's saved history") + unchanged_after_rejection(uncached, destination, 409) + + old, new, kind = cases[6] + (sd / store_path(old, kind, True)).write_bytes(b"corrupt clipping store") + unchanged_after_rejection(old, new, 500) + + # Force recent-store replacement to fail after the book and + # cache moved. This must restore both plus staged saved items. + blocked_temp = sd / ".crosspoint/recent.json.tmp" + blocked_temp.mkdir() + (blocked_temp / "block").write_bytes(b"Cannot replace this directory") + unchanged_after_rejection(cases[7][0], cases[7][1], 500) + (blocked_temp / "block").unlink() + blocked_temp.rmdir() + + for old, new, kind in cases[:5]: + old_cache = sd / cache_path(old, kind, hashes) + new_cache = sd / cache_path(new, kind, hashes) + cache_before = tree_contents(old_cache) + book_before = (sd / old.lstrip("/")).read_bytes() + status, message = rename(old, new) + assert status == 200, (old, status, message) + assert not (sd / old.lstrip("/")).exists() + assert (sd / new.lstrip("/")).read_bytes() == book_before + assert not old_cache.exists() + assert tree_contents(new_cache) == cache_before + for clipping in (False, True) if kind == "epub" else (False,): + assert not (sd / store_path(old, kind, clipping)).exists() + fields, records = store_contents((sd / store_path(new, kind, clipping)).read_bytes()) + assert fields == [TITLE, AUTHOR, new] + assert records == store_contents(store_fixture(old, clipping))[1] + entry = next(book for book in json.loads(recent_path.read_text())["books"] + if book["path"] == new) + assert entry["coverBmpPath"] == "/" + str(new_cache.relative_to(sd) / "thumb.bmp") + assert entry["title"] == TITLE and entry["author"] == AUTHOR + assert global_stats.read_bytes() == b"Global reading history stays intact." + assert json.loads(state_path.read_text())["openEpubPath"] == cases[0][1] + checks += 1 + print(f"PASS: full book-state migration ({Path(old).name})") + + for filename in ("Unopened.epub", "image.png"): + old = "/books/" + filename + new = "/books/Renamed " + filename + (sd / old.lstrip("/")).write_bytes(b"Uncached file") + status, message = rename(old, new) + assert status == 200, (filename, status, message) + assert (sd / new.lstrip("/")).read_bytes() == b"Uncached file" + assert not (sd / old.lstrip("/")).exists() + checks += 1 + print(f"PASS: rename without cached book state ({filename})") + + pinned_renamed = "/books/pinned-renamed.bmp" + status, message = rename(pinned_image, pinned_renamed) + assert status == 200, (status, message) + state = json.loads(state_path.read_text()) + assert state["favoriteSleepImagePath"] == pinned_renamed + assert state["favoriteBootImagePath"] == pinned_renamed + assert (sd / pinned_renamed.lstrip("/")).read_bytes() == b"Pinned image bytes" + checks += 1 + print("PASS: pinned boot and sleep image references follow portal rename") + + # If the resume reference cannot be saved or rolled back, the + # helper keeps the new filename and data together. The portal + # must report this partial recovery instead of claiming success. + old = cases[0][1] + new = "/books/Partial recovery.epub" + cache_before = tree_contents(sd / cache_path(old, "epub", hashes)) + saved_state = state_path.read_bytes() + state_path.unlink() + blocked_state = state_path + # Force an actual file-open failure through the native HAL. + blocked_state.symlink_to(root / "missing-state/state.json") + status, message = rename(old, new) + assert status == 500 and message.startswith("File was renamed"), (status, message) + assert not (sd / old.lstrip("/")).exists() + assert (sd / new.lstrip("/")).read_bytes() == b"Book contents must survive a rename.\n" + assert tree_contents(sd / cache_path(new, "epub", hashes)) == cache_before + for clipping in (False, True): + assert not (sd / store_path(old, "epub", clipping)).exists() + fields, records = store_contents((sd / store_path(new, "epub", clipping)).read_bytes()) + assert fields == [TITLE, AUTHOR, new] + assert records == store_contents(store_fixture(old, clipping))[1] + assert any(book["path"] == new for book in json.loads(recent_path.read_text())["books"]) + checks += 1 + print("PASS: incomplete reference recovery reports the actual filename and preserves book data") + unchanged_after_rejection(pinned_renamed, "/books/pinned-rejected.bmp", 500) + blocked_state.unlink() + state_path.write_bytes(saved_state) + status, message = rename(pinned_renamed, "/books/pinned-final.bmp") + assert status == 200, (status, message) + state = json.loads(state_path.read_text()) + assert state["openEpubPath"] == new + assert state["favoriteSleepImagePath"] == "/books/pinned-final.bmp" + assert state["favoriteBootImagePath"] == "/books/pinned-final.bmp" + checks += 1 + print("PASS: restored storage can persist the recovered book and pinned image references") + print(f"{checks} web rename checks passed.") + except BaseException: + print(log_path.read_text()[-12000:]) + raise + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--program", type=Path, default=ROOT / ".pio/build/simulator/program") + args = parser.parse_args() + run(args.program.resolve()) diff --git a/src/BookmarkStore.cpp b/src/BookmarkStore.cpp index b7fc3701a21..71e2b2ad357 100644 --- a/src/BookmarkStore.cpp +++ b/src/BookmarkStore.cpp @@ -683,6 +683,14 @@ bool BookmarkStore::migrateForFilePath(const std::string& oldFilePath, const std return true; } +bool BookmarkStore::hasStoredStateForFilePath(const std::string& filePath, const std::string& bookType) { + const std::string paths[] = {currentStoreFilePathForBook(filePath, bookType), + legacyStoreFilePathForBook(filePath, bookType)}; + return std::any_of(std::begin(paths), std::end(paths), [](const std::string& path) { + return Storage.exists(path.c_str()) || Storage.exists((path + ".rename.bak").c_str()); + }); +} + bool BookmarkStore::beginRenameMigration(const std::string& oldFilePath, const std::string& newFilePath, const std::string& title, const std::string& author, const std::string& bookType, RenameMigration& migration) { diff --git a/src/BookmarkStore.h b/src/BookmarkStore.h index 0987b75ba60..0666605d897 100644 --- a/src/BookmarkStore.h +++ b/src/BookmarkStore.h @@ -77,6 +77,7 @@ class BookmarkStore { // oldFilePath and newFilePath must refer to the same logical book. static bool migrateForFilePath(const std::string& oldFilePath, const std::string& newFilePath, const std::string& title, const std::string& author, const std::string& bookType); + static bool hasStoredStateForFilePath(const std::string& filePath, const std::string& bookType); // Transactional variant used by file rename. Source files and destination // backups remain in place until commit, so a later failure can restore both diff --git a/src/ClippingStore.cpp b/src/ClippingStore.cpp index 8ec1e2679cf..a262340fb71 100644 --- a/src/ClippingStore.cpp +++ b/src/ClippingStore.cpp @@ -577,6 +577,13 @@ bool ClippingStore::migrateForFilePath(const std::string& oldFilePath, const std return true; } +bool ClippingStore::hasStoredStateForFilePath(const std::string& filePath, const std::string& bookType) { + const std::string path = storeFilePathForBook(filePath, bookType); + constexpr const char* suffixes[] = {"", ".bak", ".tmp", ".rename.bak"}; + return std::any_of(std::begin(suffixes), std::end(suffixes), + [&path](const char* suffix) { return Storage.exists((path + suffix).c_str()); }); +} + bool ClippingStore::beginRenameMigration(const std::string& oldFilePath, const std::string& newFilePath, const std::string& title, const std::string& author, const std::string& bookType, RenameMigration& migration) { diff --git a/src/ClippingStore.h b/src/ClippingStore.h index 468ca4490ee..e739a8c739a 100644 --- a/src/ClippingStore.h +++ b/src/ClippingStore.h @@ -99,6 +99,7 @@ class ClippingStore { static void deleteForFilePath(const std::string& filePath, const std::string& bookType); static bool migrateForFilePath(const std::string& oldFilePath, const std::string& newFilePath, const std::string& title, const std::string& author, const std::string& bookType); + static bool hasStoredStateForFilePath(const std::string& filePath, const std::string& bookType); static bool beginRenameMigration(const std::string& oldFilePath, const std::string& newFilePath, const std::string& title, const std::string& author, const std::string& bookType, RenameMigration& migration); diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index 25c7a0bbaeb..08c01cba371 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -62,12 +62,10 @@ bool CrossPointState::saveToFile() const { String json; serializeJson(doc, json); - // Many callers save unconditionally; skip rewriting what is already on disk. - // Every external edit of the card (web, WebDAV, USB Drive) ends in a reboot, - // which resets this, so it cannot mask a deleted file. + // Many callers save unconditionally; skip rewriting what is already on disk, + // unless the file has since been deleted. const uint32_t crc = uzlib_crc32(json.c_str(), json.length(), 0); - const bool unchanged = lastSavedCrcValid && crc == lastSavedCrc; - if (unchanged) return true; + if (lastSavedCrcValid && crc == lastSavedCrc && Storage.exists(STATE_FILE_JSON)) return true; Storage.mkdir("/.crosspoint"); if (!Storage.writeFile(STATE_FILE_JSON, json)) { @@ -81,13 +79,16 @@ bool CrossPointState::saveToFile() const { } bool CrossPointState::loadFromFile() { - // Try JSON first - if (Storage.exists(STATE_FILE_JSON)) { + { std::lock_guard storeLock(storeMutex); - JsonDocument doc; - if (PersistableStoreBase::readDocFromFile(STATE_FILE_JSON, doc)) { - std::lock_guard stateLock(_mutex); - return fromJson(doc.as()); + lastSavedCrcValid = false; + // Try JSON first. A reload invalidates the remembered on-disk snapshot. + if (Storage.exists(STATE_FILE_JSON)) { + JsonDocument doc; + if (PersistableStoreBase::readDocFromFile(STATE_FILE_JSON, doc)) { + std::lock_guard stateLock(_mutex); + return fromJson(doc.as()); + } } } diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index f448e1ac3f1..2c8ca98ef00 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -9,6 +9,7 @@ #include #include "CrossPointSettings.h" +#include "DeviceCapabilities.h" #include "GlobalActions.h" #if CROSSINK_APP_CAP_TOUCH #include "components/TouchRegistry.h" @@ -151,6 +152,22 @@ void MappedInputManager::expireReleaseSuppressions() const { releaseSuppression.expireAfterReleaseFrame(state); } +MappedInputManager::Button MappedInputManager::menuButton(const Button direction) const { + if (!deviceUsesHorizontalSideButtonsForMenus(gpio)) return direction; + switch (direction) { + case Button::Left: + return Button::Up; + case Button::Right: + return Button::Down; + case Button::Up: + return Button::Left; + case Button::Down: + return Button::Right; + default: + return direction; + } +} + bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const { const ButtonIndex up = mapSideButtonForReaderOrientation(HalGPIO::BTN_UP, readerMode); const ButtonIndex down = mapSideButtonForReaderOrientation(HalGPIO::BTN_DOWN, readerMode); @@ -698,6 +715,7 @@ bool MappedInputManager::getEdgeSlideProgress(EdgeSlideProgress& progress) { (edgeSlideSide == EdgeSlide::RightUp && x < width - band)) { edgeSlideSide = EdgeSlide::None; progress.finished = true; + progress.leftEdgeBand = true; return true; } else { edgeSlideLastX = x; @@ -731,6 +749,7 @@ bool MappedInputManager::getEdgeSlideProgress(EdgeSlideProgress& progress) { const EdgeSlide side = edgeSlideSide; if ((side == EdgeSlide::LeftUp && x >= band) || (side == EdgeSlide::RightUp && x < width - band)) { edgeSlideSide = EdgeSlide::None; + progress.leftEdgeBand = true; return true; } progress.deltaY = y - edgeSlideStartY; diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index 45b490b3c26..951dc656f14 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -23,6 +23,7 @@ class MappedInputManager { // whenever a position is known, even inside the recognition dead zone. int deltaY = 0; bool finished = false; + bool leftEdgeBand = false; }; struct CompletedSwipe { @@ -251,6 +252,8 @@ class MappedInputManager { constexpr bool wasReaderMenuHold() const { return false; } // cppcheck-suppress-end functionStatic #endif + // Directions for tabbed menus, derived from the physical button layout. + Button menuButton(Button direction) const; bool wasAnyPressed() const; bool wasAnyReleased() const; unsigned long getHeldTime() const; diff --git a/src/SettingsList.cpp b/src/SettingsList.cpp index 658798b8e10..7058b3a1a56 100644 --- a/src/SettingsList.cpp +++ b/src/SettingsList.cpp @@ -179,8 +179,8 @@ const std::vector& getBaseSettingsList() { "rightEdgeDown", StrId::STR_CAT_CONTROLS) .withEnumRawValues(twoFingerSwipeActionValues)); #endif - add(SettingInfo::Enum(StrId::STR_ORIENTATION_AWARE, &CrossPointSettings::sideButtonOrientationAware, - {StrId::STR_NO, StrId::STR_YES}, "sideButtonOrientationAware", StrId::STR_CAT_CONTROLS)); + add(SettingInfo::Toggle(StrId::STR_ORIENTATION_AWARE, &CrossPointSettings::sideButtonOrientationAware, + "sideButtonOrientationAware", StrId::STR_CAT_CONTROLS)); add(buildSideButtonActionSetting(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::sideButtonUpShort, "sideButtonUpShort")); add(buildSideButtonActionSetting(StrId::STR_LONG_PRESS_ACTION, &CrossPointSettings::sideButtonUpLong, diff --git a/src/activities/Activity.h b/src/activities/Activity.h index 5e81aaa4b60..b55090912a4 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -54,6 +54,9 @@ class Activity { virtual RequestUpdateResult requestUpdateAndWait(); virtual bool skipLoopDelay() { return false; } + // Read on the main task even while render() holds the render lock. + // Overrides must return a fixed value that does not depend on render state. + virtual uint8_t inputPollDelayMs() const { return 10; } virtual bool preventAutoSleep() { return false; } // While true, main-loop global controls and activity replacement are // suspended so an exclusive storage owner cannot race the filesystem. diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index c968a651d7c..abdf3333bb4 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -395,9 +395,12 @@ bool applyEdgeSlideAction(Activity& activity, MappedInputManager& mappedInput, A if (state.active) { // Track the finger in both directions: reversing through the touch-down // point keeps moving the value instead of holding at the starting value. - const int amount = - SwipeAdjustment::liveAmount(state.movementSign * progress.deltaY, mappedInput.getRenderer().getScreenHeight()); - updateLiveLightSwipe(activity, activityManager, state, amount); + // Drifting inward ends the slide at its last applied value. + if (!progress.leftEdgeBand) { + const int amount = SwipeAdjustment::liveAmount(state.movementSign * progress.deltaY, + mappedInput.getRenderer().getScreenHeight()); + updateLiveLightSwipe(activity, activityManager, state, amount); + } if (progress.finished) { mappedInput.suppressCurrentTouchContact(); finishLiveLightSwipe(state, activityManager); @@ -1075,15 +1078,18 @@ void ActivityManager::goToSleep(bool fromTimeout) { const GfxRenderer::Orientation sleepPopupOrientation = renderer.getOrientation(); std::string currentBookPath = getCurrentBookPath(); const bool renderBeforeExit = currentActivity && SleepActivity::rendersBeforeExit(currentBookPath, fromTimeout); - auto sleepActivity = std::make_unique(renderer, mappedInput, canSnapshotOverlay, - std::move(currentBookPath), fromTimeout, sleepPopupOrientation); - if (renderBeforeExit) { + auto sleepActivity = makeUniqueNoThrow(renderer, mappedInput, canSnapshotOverlay, + std::move(currentBookPath), fromTimeout, sleepPopupOrientation); + if (!sleepActivity) { + LOG_ERR("ACT", "Could not allocate sleep activity; saving outgoing activities before sleep"); + } + if (renderBeforeExit || !sleepActivity) { // Draw the sleep screen first, then let the outgoing activities save their // progress and stats. Holding the render lock throughout keeps the render // task from repainting the outgoing activity over the sleep screen. RenderLock lock; TouchRegistry::getInstance().clear(); - sleepActivity->onEnter(); + if (sleepActivity) sleepActivity->onEnter(); exitActivity(lock); while (!stackActivities.empty()) { stackActivities.back()->onExit(); @@ -1251,6 +1257,8 @@ void ActivityManager::endGlobalSettingsEdit() { bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); } +uint8_t ActivityManager::inputPollDelayMs() const { return currentActivity ? currentActivity->inputPollDelayMs() : 10; } + std::string ActivityManager::getCurrentBookPath() const { if (currentActivity) { const std::string path = currentActivity->getCurrentBookPath(); diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index 8c53c81ab0c..1ec5e962985 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -183,6 +184,7 @@ class ActivityManager { bool hasActivityNamed(const char* activityName) const; #ifdef SIMULATOR bool isCurrentActivityNamed(const char* activityName) const; + Activity* simulatorCurrentActivity() const { return currentActivity.get(); } #endif bool canSnapshotForSleepOverlay() const; bool requestManualReaderRefresh(); @@ -194,6 +196,7 @@ class ActivityManager { void notifyInputLockChanged(bool locked); void notifyUserInput(); bool skipLoopDelay() const; + uint8_t inputPollDelayMs() const; std::string getCurrentBookPath() const; ScreenshotInfo getScreenshotInfo() const; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 809e956ff23..accec657022 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include // PNGdec's bundled zlib internals leak this macro into later FreeInkUI headers. #undef local diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index 940485b1e71..d7466bff240 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -897,55 +897,15 @@ void FileBrowserActivity::renameFile(const std::string& oldPath, const std::stri return; } - std::string oldCachePath; - const char* bookType = nullptr; - if (FsHelpers::hasEpubExtension(oldPath)) { - oldCachePath = Epub::cachePathForFilePath(oldPath, "/.crosspoint"); - bookType = "epub"; - } else if (FsHelpers::hasXtcExtension(oldPath)) { - oldCachePath = Xtc(oldPath, "/.crosspoint").getCachePath(); - bookType = "xtc"; - } else if (FsHelpers::hasTxtExtension(oldPath) || FsHelpers::hasMarkdownExtension(oldPath)) { - oldCachePath = Txt(oldPath, "/.crosspoint").getCachePath(); - bookType = "txt"; - } - - if (bookType) { - std::string title = getFileName(oldEntry); - std::string author; - const auto& recentBooks = RECENT_BOOKS.getBooks(); - const auto recent = std::find_if(recentBooks.begin(), recentBooks.end(), - [&oldPath](const RecentBook& book) { return book.path == oldPath; }); - if (recent != recentBooks.end()) { - if (!recent->title.empty()) title = recent->title; - author = recent->author; - } - const auto migration = - BookMoveUtils::migrateRenamedBookState(oldPath, newPath, oldCachePath, title, author, bookType); - if (migration == BookMoveUtils::RenameMigrationResult::RolledBack) { - LOG_ERR("FileBrowser", "Could not rename book while preserving reader state: %s -> %s", oldPath.c_str(), - newPath.c_str()); - return; - } - if (migration == BookMoveUtils::RenameMigrationResult::KeepRenamed) { - LOG_ERR("FileBrowser", "Rename kept new path after incomplete state rollback: %s", newPath.c_str()); - } - } else if (!Storage.rename(oldPath.c_str(), newPath.c_str())) { - LOG_ERR("FileBrowser", "Failed to rename file: %s -> %s", oldPath.c_str(), newPath.c_str()); + const auto migration = BookMoveUtils::renameFilePreservingBookState(oldPath, newPath); + if (migration != BookMoveUtils::RenameMigrationResult::Success && + migration != BookMoveUtils::RenameMigrationResult::KeepRenamed) { + LOG_ERR("FileBrowser", "Could not rename file while preserving reader state: %s -> %s", oldPath.c_str(), + newPath.c_str()); return; } - - bool appStateChanged = false; - if (APP_STATE.favoriteSleepImagePath == oldPath) { - APP_STATE.favoriteSleepImagePath = newPath; - appStateChanged = true; - } - if (APP_STATE.favoriteBootImagePath == oldPath) { - APP_STATE.favoriteBootImagePath = newPath; - appStateChanged = true; - } - if (appStateChanged && !APP_STATE.saveToFile()) { - LOG_ERR("FileBrowser", "Failed to save renamed favorite image path"); + if (migration == BookMoveUtils::RenameMigrationResult::KeepRenamed) { + LOG_ERR("FileBrowser", "Rename kept new path after incomplete state rollback: %s", newPath.c_str()); } library::invalidateLibraryIndex(); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index f04a1349398..9dac205b7ec 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -17,7 +17,6 @@ #include #include -#include #include #include #include @@ -48,10 +47,10 @@ namespace { constexpr uint32_t CAROUSEL_CACHE_MAGIC = 0x43434152; // "CCAR" -// Cached frames include all Home visuals, including the menu icons. Bump this -// whenever their rendering changes so stale snapshots are rebuilt after OTA. -constexpr uint16_t CAROUSEL_CACHE_VERSION = 7; -constexpr char CAROUSEL_CACHE_PATH[] = "/.crosspoint/home_carousel_cache.bin"; +// Cached frames contain only carousel artwork. Bump this whenever its +// rendering changes so stale snapshots are rebuilt after OTA. +constexpr uint16_t CAROUSEL_CACHE_VERSION = 6; +constexpr char LEGACY_CAROUSEL_CACHE_PATH[] = "/.crosspoint/home_carousel_cache.bin"; constexpr char CAROUSEL_CACHE_TMP_PATH[] = "/.crosspoint/home_carousel_cache.tmp"; constexpr uint32_t CAROUSEL_FRAME_MIN_FREE_AFTER_ALLOC = 64U * 1024U; constexpr uint32_t CAROUSEL_FRAME_MIN_MAX_ALLOC_AFTER_ALLOC = 24U * 1024U; @@ -132,34 +131,6 @@ bool hasHeapForCarouselFrameCache() { ESP.getMaxAllocHeap() >= CAROUSEL_FRAME_MIN_MAX_ALLOC_AFTER_ALLOC; } -void appendHashedFileStateToKey(std::string& key, const std::string& path) { - FsFile file; - if (!Storage.openFileForRead("HOME", path, file)) { - key += "missing"; - key += '\0'; - return; - } - - uint64_t hash = 14695981039346656037ull; - size_t totalBytes = 0; - uint8_t buffer[64]; - while (true) { - const int bytesRead = file.read(buffer, sizeof(buffer)); - if (bytesRead <= 0) break; - totalBytes += static_cast(bytesRead); - for (int i = 0; i < bytesRead; ++i) { - hash ^= buffer[i]; - hash *= 1099511628211ull; - } - } - file.close(); - - char digest[48]; - snprintf(digest, sizeof(digest), "%zu:%" PRIu64, totalBytes, static_cast(hash)); - key += digest; - key += '\0'; -} - std::string getRecentBookCachePath(const RecentBook& book) { if (FsHelpers::hasEpubExtension(book.path)) { return Epub::resolveCachePathForFilePath(book.path, "/.crosspoint"); @@ -410,6 +381,8 @@ std::string dashboardHomeCoverPath(const RecentBook& book, int coverHeight) { void appendCarouselCoverStateToKey(std::string& key, const RecentBook& book) { key += book.path; key += '\0'; + key += book.title; + key += '\0'; key += book.coverBmpPath; key += '\0'; @@ -427,88 +400,36 @@ void appendCarouselCoverStateToKey(std::string& key, const RecentBook& book) { key += ':'; key += Storage.exists(sidePath.c_str()) ? '1' : '0'; key += '\0'; - - const std::string cachePath = getRecentBookCachePath(book); - if (!cachePath.empty()) { - appendHashedFileStateToKey(key, cachePath + "/progress.bin"); - // EPUB progress alternates between two slots; either can hold the latest save. - 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"; - key += '\0'; - } -} - -void appendSyncedStatsStateToKey(std::string& key) { - FsFile dir = Storage.open("/.crosspoint/synced_stats"); - if (!dir) { - key += "no-synced-stats"; - key += '\0'; - return; - } - - if (!dir.isDirectory()) { - dir.close(); - key += "synced-stats-not-dir"; - key += '\0'; - return; - } - - char name[128]; - for (FsFile file = dir.openNextFile(); file; file = dir.openNextFile()) { - const bool isDirectory = file.isDirectory(); - const size_t nameLen = file.getName(name, sizeof(name)); - if (!isDirectory && nameLen > 0) { - key += name; - key += '\0'; - file.close(); - appendHashedFileStateToKey(key, std::string("/.crosspoint/synced_stats/") + name); - continue; - } - file.close(); - } - dir.close(); -} - -void appendCarouselMenuStateToKey(std::string& key, const bool hasOpdsServers, const bool hasReadingStats, - const bool hasBookmarks, const bool hasClippings) { - key += hasOpdsServers ? "opds:1" : "opds:0"; - key += '\0'; - key += hasReadingStats ? "stats:1" : "stats:0"; - key += '\0'; - key += hasBookmarks ? "bookmarks:1" : "bookmarks:0"; - key += '\0'; - key += hasClippings ? "clippings:1" : "clippings:0"; - key += '\0'; } -void buildCarouselCacheKey(const std::vector& recentBooks, const bool hasOpdsServers, - const bool hasReadingStats, const bool hasBookmarks, const bool hasClippings, - std::string& key, uint64_t& keyHash) { +void buildCarouselCacheKey(const std::vector& recentBooks, std::string& key, uint64_t& keyHash) { key.clear(); key.reserve(512); - // Framebuffer snapshots include both UI pixels and counter-inverted cover - // pixels, so a Light Mode snapshot cannot be reused in Dark Mode (or vice - // versa). + // Artwork includes Dark Mode's image-polarity correction. Progress, stats, + // headers and menus are drawn live, so reading cannot invalidate this cache. 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); for (const auto& book : recentBooks) { appendCarouselCoverStateToKey(key, book); } - appendHashedFileStateToKey(key, "/.crosspoint/global_stats.bin"); - appendSyncedStatsStateToKey(key); keyHash = fnvHash64(key); } +std::string carouselCachePath(int bookIdx) { + char path[64]; + snprintf(path, sizeof(path), "/.crosspoint/home_carousel_cache_%d.bin", bookIdx); + return path; +} + +void invalidateCarouselDiskCache() { + for (int i = 0; i < HomeActivity::kMaxCachedBooks; ++i) { + const std::string path = carouselCachePath(i); + if (Storage.exists(path.c_str())) Storage.remove(path.c_str()); + } + if (Storage.exists(LEGACY_CAROUSEL_CACHE_PATH)) Storage.remove(LEGACY_CAROUSEL_CACHE_PATH); + if (Storage.exists(CAROUSEL_CACHE_TMP_PATH)) Storage.remove(CAROUSEL_CACHE_TMP_PATH); +} + bool isCarouselCacheHeaderValid(const CarouselCacheHeader& header, uint64_t cacheKeyHash, int bookCount, const GfxRenderer& renderer) { return header.magic == CAROUSEL_CACHE_MAGIC && header.version == CAROUSEL_CACHE_VERSION && @@ -528,19 +449,16 @@ bool readCarouselCacheHeader(FsFile& file, CarouselCacheHeader& header) { return true; } -bool hasValidCarouselDiskCache(const std::vector& recentBooks, const GfxRenderer& renderer, - const bool hasOpdsServers, const bool hasReadingStats, const bool hasBookmarks, - const bool hasClippings) { +bool hasValidCarouselDiskCache(const std::vector& recentBooks, const GfxRenderer& renderer, int bookIdx) { const int bookCount = static_cast(recentBooks.size()); - if (bookCount <= 0) return false; + if (bookIdx < 0 || bookIdx >= bookCount) return false; std::string cacheKey; uint64_t cacheKeyHash = 0; - buildCarouselCacheKey(recentBooks, hasOpdsServers, hasReadingStats, hasBookmarks, hasClippings, cacheKey, - cacheKeyHash); + buildCarouselCacheKey(recentBooks, cacheKey, cacheKeyHash); FsFile cacheFile; - if (!Storage.openFileForRead("HOME", CAROUSEL_CACHE_PATH, cacheFile)) { + if (!Storage.openFileForRead("HOME", carouselCachePath(bookIdx), cacheFile)) { return false; } @@ -928,45 +846,14 @@ void HomeActivity::loadRecentCovers(int coverHeight) { recentsLoaded = true; recentsLoading = false; - // Re-render only the affected slots rather than rebuilding the entire cache. - if (isCarouselTheme) { - bool anyUpdated = false; - for (int i = 0; i < static_cast(recentBooks.size()); ++i) { - if (static_cast(i) >= bookUpdated.size() || !bookUpdated[i]) continue; - anyUpdated = true; - if (carouselFramesReady) { - // Only re-render the slot holding this book; books outside the window - // will be picked up by updateSlidingWindowCache on next navigation. - const int slot = gCarouselCache.findFrameSlot(i); - if (slot >= 0) renderCarouselFrame(i, slot); - } - } - if (anyUpdated) { - if (!carouselFramesReady) { - // Cover assets changed before the carousel cache was initialised, so - // any existing SD snapshot may still contain placeholder frames. - // Force a rebuild from the fresh thumbs instead of reusing stale - // `home_carousel_cache.bin` content keyed only by book order/layout. - if (Storage.exists(CAROUSEL_CACHE_PATH)) { - Storage.remove(CAROUSEL_CACHE_PATH); - } - if (Storage.exists(CAROUSEL_CACHE_TMP_PATH)) { - Storage.remove(CAROUSEL_CACHE_TMP_PATH); - } - preRenderCarouselFrames(); - } else { - // The live carousel frames are already updated above. Keep Home - // responsive by invalidating any stale SD snapshot instead of - // rewriting all 5 frames synchronously on this return-to-Home path. - if (Storage.exists(CAROUSEL_CACHE_PATH)) { - Storage.remove(CAROUSEL_CACHE_PATH); - } - if (Storage.exists(CAROUSEL_CACHE_TMP_PATH)) { - Storage.remove(CAROUSEL_CACHE_TMP_PATH); - } - } - requestUpdate(); - } + if (isCarouselTheme && std::any_of(bookUpdated.begin(), bookUpdated.end(), [](char updated) { return updated; })) { + // A changed cover affects the centre and side artwork in every position. + // Rebuild only the viewed position; other positions remain lazy. + invalidateCarouselDiskCache(); + freeCarouselFrames(); + gCarouselCache.invalidate(); + preRenderCarouselFrames(); + requestUpdate(); } } @@ -1077,9 +964,8 @@ void HomeActivity::onEnter() { } } - if (isCarouselTheme && - hasValidCarouselDiskCache(recentBooks, renderer, hasOpdsServers, hasReadingStats, hasBookmarks, hasClippings)) { - preRenderCarouselFrames(false); + if (isCarouselTheme && hasValidCarouselDiskCache(recentBooks, renderer, getHighlightedBookIndex())) { + preRenderCarouselFrames(); } requestUpdate(); @@ -1165,7 +1051,27 @@ std::unique_ptr HomeActivity::createFrontlightReadingStatsActivity() { bookStats, progress, false, 0, deviceStats); } +void HomeActivity::onFrontlightPanelOpened() { + themeBeforeFrontlightPanel = SETTINGS.uiTheme; + scaleBeforeFrontlightPanel = SETTINGS.uiScale; + // Save the selection before changed theme metrics can reinterpret its index. + initialBookPath = getCurrentBookPath(); +} + void HomeActivity::onFrontlightPanelClosed() { + if (themeBeforeFrontlightPanel != SETTINGS.uiTheme || scaleBeforeFrontlightPanel != SETTINGS.uiScale) { + // Drawer Settings keeps Home alive. Recreate its theme-specific controls, + // cover snapshots and thumbnail loading state through the normal lifecycle. + // ActivityManager owns the replacement; its heavy caches allocate onEnter, + // after the outgoing Home has released its buffers in onExit. + auto home = makeUniqueNoThrow(renderer, mappedInput, HomeMenuItem::NONE, HalDisplay::FAST_REFRESH, + initialBookPath); + if (home) { + activityManager.replaceActivity(std::move(home)); + return; + } + LOG_ERR("HOME", "Cannot rebuild Home after theme or UI scale change"); + } globalStats = GlobalReadingStats::load(); showAllDevicesStats = GlobalReadingStats::hasSyncedStats(); allDevicesGlobalStats = showAllDevicesStats ? GlobalReadingStats::loadAggregated(globalStats) : globalStats; @@ -1365,62 +1271,24 @@ bool HomeActivity::allocateCarouselFrameSlots(int targetFrameCount) { return true; } -void HomeActivity::renderCarouselFrameToCurrentBuffer(int bookIdx, BookReadingStats* outStats, - float* outProgressPercent, bool* outUsedCachedStats) { +void HomeActivity::renderCarouselFrameToCurrentBuffer(int bookIdx) { const auto& metrics = UITheme::getInstance().getMetrics(); - const int pageWidth = renderer.getScreenWidth(); - const int pageHeight = renderer.getScreenHeight(); - const int bookCount = static_cast(recentBooks.size()); - bool dummy1 = false, dummy2 = false, dummy3 = false; - BookReadingStats frameStats; - const BookReadingStats* frameStatsPtr = nullptr; - float frameProgressPercent = -1.0f; - bool usedCachedStats = false; - - if (bookIdx >= 0 && bookIdx < bookCount) { - if (bookStatsCached && bookIdx < kMaxCachedBooks) { - usedCachedStats = true; - frameStats = visibleRecentBookStats(recentBooks[bookIdx], cachedBookStats[bookIdx]); - frameProgressPercent = cachedBookProgress[bookIdx]; - } else { - frameStats = loadRecentBookStats(recentBooks[bookIdx]); - frameProgressPercent = loadRecentBookProgress(recentBooks[bookIdx]); - } - if (hasAnyBookStats(frameStats)) frameStatsPtr = &frameStats; - } - + bool coverRendered = false, coverBufferStored = false, bufferRestored = false; LyraCarouselTheme::setPreRenderIndex(bookIdx); renderer.clearScreen(); - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr); + // Snapshot the expensive artwork only. Fresh progress/stats and controls are + // added after restoring it, without rereading or repainting the covers. GUI.drawRecentBookCover( - renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks, bookCount, dummy1, - dummy2, dummy3, []() { return true; }, frameStatsPtr, frameProgressPercent); - - const bool frameHasReadingStats = - SETTINGS.shouldTrackReadingStats() && (hasAnyBookStats(frameStats) || hasAnyGlobalStats(globalStats) || - (showAllDevicesStats && hasAnyGlobalStats(allDevicesGlobalStats))); - const auto menuItems = buildHomeMenuItems(hasOpdsServers, frameHasReadingStats, hasBookmarks, hasClippings); - GUI.drawButtonMenu( - renderer, - Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.verticalSpacing, pageWidth, - pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing * 2 + - metrics.buttonHintsHeight)}, - static_cast(menuItems.size()), -1, [&menuItems](int index) { return menuItems[index].label; }, - [&menuItems](int index) { return menuItems[index].icon; }); - - const auto labels = mappedInput.mapLabels(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); - - if (outStats) *outStats = frameStats; - if (outProgressPercent) *outProgressPercent = frameProgressPercent; - if (outUsedCachedStats) *outUsedCachedStats = usedCachedStats; + renderer, Rect{0, metrics.homeTopPadding, renderer.getScreenWidth(), metrics.homeCoverTileHeight}, recentBooks, + static_cast(recentBooks.size()), coverRendered, coverBufferStored, bufferRestored, []() { return true; }); } -bool HomeActivity::buildCarouselCacheFile(const std::string& cacheKey, uint64_t cacheKeyHash, int bookCount, - bool showProgressPopup) { - (void)cacheKey; - uint8_t* frameBuffer = renderer.getFrameBuffer(); - if (!frameBuffer || bookCount <= 0) return false; +bool HomeActivity::saveCarouselFrameToDisk(uint64_t cacheKeyHash, int bookCount, int bookIdx, int slotIdx) { + if (slotIdx < 0 || slotIdx >= kCarouselFrameCount || !carouselFrames[slotIdx] || bookIdx < 0 || + bookIdx >= bookCount) { + return false; + } + const std::string cachePath = carouselCachePath(bookIdx); Storage.mkdir("/.crosspoint"); if (Storage.exists(CAROUSEL_CACHE_TMP_PATH)) { @@ -1453,60 +1321,30 @@ bool HomeActivity::buildCarouselCacheFile(const std::string& cacheKey, uint64_t } const auto start = millis(); - Rect popupRect{}; - uint8_t* progressFrameBuffer = nullptr; - const size_t bufferSize = renderer.getBufferSize(); - if (showProgressPopup) { - progressFrameBuffer = static_cast(malloc(bufferSize)); - if (!progressFrameBuffer) { - LOG_ERR("HOME", "carousel: failed to allocate progress overlay buffer"); - showProgressPopup = false; - } else { - popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); - GUI.fillPopupProgress(renderer, popupRect, 0); - memcpy(progressFrameBuffer, frameBuffer, bufferSize); - } - } - bool writeFailed = false; - for (int i = 0; i < bookCount; ++i) { - const int cachedSlot = gCarouselCache.findFrameSlot(i); - if (cachedSlot >= 0 && carouselFrames[cachedSlot]) { - memcpy(frameBuffer, carouselFrames[cachedSlot], renderer.getBufferSize()); - } else { - renderCarouselFrameToCurrentBuffer(i, nullptr, nullptr, nullptr); - } - if (file.write(frameBuffer, renderer.getBufferSize()) != renderer.getBufferSize()) { - writeFailed = true; - break; - } - if (showProgressPopup) { - memcpy(frameBuffer, progressFrameBuffer, bufferSize); - GUI.fillPopupProgress(renderer, popupRect, ((i + 1) * 100) / bookCount); - } - } + // Save only the viewed position. Other positions are prepared when selected, + // so entering Home never renders or writes the entire carousel in advance. + const bool writeFailed = file.write(carouselFrames[slotIdx], renderer.getBufferSize()) != renderer.getBufferSize(); const bool syncOk = file.sync(); file.close(); if (writeFailed || !syncOk) { - free(progressFrameBuffer); Storage.remove(CAROUSEL_CACHE_TMP_PATH); LOG_ERR("HOME", "carousel: failed to write SD cache snapshot"); return false; } - if (Storage.exists(CAROUSEL_CACHE_PATH)) { - Storage.remove(CAROUSEL_CACHE_PATH); + if (Storage.exists(cachePath.c_str())) { + Storage.remove(cachePath.c_str()); } - if (!Storage.rename(CAROUSEL_CACHE_TMP_PATH, CAROUSEL_CACHE_PATH)) { - free(progressFrameBuffer); + if (!Storage.rename(CAROUSEL_CACHE_TMP_PATH, cachePath.c_str())) { Storage.remove(CAROUSEL_CACHE_TMP_PATH); LOG_ERR("HOME", "carousel: failed to promote SD cache snapshot"); return false; } - free(progressFrameBuffer); - LOG_DBG("HOME", "carousel: built SD cache for %d book(s) in %lums", bookCount, millis() - start); + if (Storage.exists(LEGACY_CAROUSEL_CACHE_PATH)) Storage.remove(LEGACY_CAROUSEL_CACHE_PATH); + LOG_DBG("HOME", "carousel: saved SD artwork for book %d in %lums", bookIdx, millis() - start); return true; } @@ -1517,7 +1355,7 @@ bool HomeActivity::loadCarouselFrameFromDisk(uint64_t cacheKeyHash, int bookCoun } FsFile file; - if (!Storage.openFileForRead("HOME", CAROUSEL_CACHE_PATH, file)) { + if (!Storage.openFileForRead("HOME", carouselCachePath(bookIdx), file)) { return false; } @@ -1528,11 +1366,6 @@ bool HomeActivity::loadCarouselFrameFromDisk(uint64_t cacheKeyHash, int bookCoun return false; } - const size_t frameOffset = sizeof(CarouselCacheHeader) + static_cast(bookIdx) * renderer.getBufferSize(); - if (!file.seek(frameOffset)) { - file.close(); - return false; - } const size_t expectedBytes = renderer.getBufferSize(); size_t totalBytesRead = 0; while (totalBytesRead < expectedBytes) { @@ -1576,16 +1409,15 @@ int HomeActivity::chooseCarouselEvictionSlot(int centerIdx, int bookCount, std:: return evictSlot; } -bool HomeActivity::preRenderCarouselFrames(bool showProgressPopup) { +void HomeActivity::preRenderCarouselFrames() { const int bookCount = static_cast(recentBooks.size()); - if (bookCount == 0) return false; - bool showedProgressPopup = false; + if (bookCount == 0) return; // Build cache key from book paths plus thumb-asset availability so we don't // reuse a stale snapshot built before carousel-sized thumbs existed. std::string newKey; uint64_t newKeyHash = 0; - buildCarouselCacheKey(recentBooks, hasOpdsServers, hasReadingStats, hasBookmarks, hasClippings, newKey, newKeyHash); + buildCarouselCacheKey(recentBooks, newKey, newKeyHash); // Cache hit: same books in same order — reuse without any SD reads if (newKey == gCarouselCache.key && gCarouselCache.frameCount > 0) { @@ -1594,73 +1426,27 @@ bool HomeActivity::preRenderCarouselFrames(bool showProgressPopup) { carouselFramesInverted = SETTINGS.screenInverted != 0; coverRendered = false; coverBufferStored = false; - return false; + return; } // Cache miss: free old cache and re-render - if (!renderer.getFrameBuffer()) return false; + if (!renderer.getFrameBuffer()) return; freeCoverBuffer(); // reclaim 48KB before allocating frames gCarouselCache.invalidate(); - const int targetFrameCount = std::min(bookCount, kCarouselFrameCount); - bool diskCacheValid = false; - FsFile cacheFile; - if (Storage.openFileForRead("HOME", CAROUSEL_CACHE_PATH, cacheFile)) { - CarouselCacheHeader header{}; - const bool readOk = readCarouselCacheHeader(cacheFile, header); - cacheFile.close(); - diskCacheValid = readOk && isCarouselCacheHeaderValid(header, newKeyHash, bookCount, renderer); - } + if (!allocateCarouselFrameSlots(1)) return; - if (!allocateCarouselFrameSlots(targetFrameCount)) { - return showedProgressPopup; - } - - // Keep only the current frame in RAM; adjacent frames come from the SD - // snapshot on demand instead of occupying another framebuffer-sized slot. - const int selectedBookIdx = (selectorIndex < bookCount) ? selectorIndex : lastCarouselBookIndex; - const int initialBookIdx = (selectedBookIdx >= 0 && selectedBookIdx < bookCount) ? selectedBookIdx : 0; - auto loadOrRender = [&](int bookIdx, int slot) { - if (!diskCacheValid || !loadCarouselFrameFromDisk(newKeyHash, bookCount, bookIdx, slot)) { - renderCarouselFrame(bookIdx, slot); - } - }; - loadOrRender(initialBookIdx, 0); + const int initialBookIdx = getHighlightedBookIndex(); + const bool loaded = loadCarouselFrameFromDisk(newKeyHash, bookCount, initialBookIdx, 0); + if (!loaded) renderCarouselFrame(initialBookIdx, 0); gCarouselCache.lastCenterIdx = initialBookIdx; - - if (gCarouselCache.frameCount >= 2 && bookCount >= 2) { - const int nextIdx = (initialBookIdx + 1) % bookCount; - loadOrRender(nextIdx, 1); - } - - if (gCarouselCache.frameCount >= 3 && bookCount >= 3) { - const int prevIdx = (initialBookIdx + bookCount - 1) % bookCount; - loadOrRender(prevIdx, 2); - } - - const bool hasFullFrameCache = gCarouselCache.frameCount >= targetFrameCount; gCarouselCache.key = newKey; - gCarouselCache.keyHash = diskCacheValid ? newKeyHash : 0; + gCarouselCache.keyHash = newKeyHash; carouselFramesReady = true; carouselFramesInverted = SETTINGS.screenInverted != 0; coverRendered = false; coverBufferStored = false; - - // Persist the freshly-rendered carousel snapshot back to SD after Home is - // already visible so later reader->Home returns and carousel navigation can - // bootstrap from disk instead of live-rendering covers again. - if (!diskCacheValid && gCarouselCache.frameCount > 0) { - if (hasFullFrameCache) { - const bool cacheBuilt = buildCarouselCacheFile(newKey, newKeyHash, bookCount, showProgressPopup); - if (cacheBuilt) { - gCarouselCache.keyHash = newKeyHash; - showedProgressPopup = true; - } - } else { - LOG_INF("HOME", "carousel: skipping SD cache build in degraded frame cache mode"); - } - } - return showedProgressPopup; + if (!loaded) saveCarouselFrameToDisk(newKeyHash, bookCount, initialBookIdx, 0); } void HomeActivity::onUserInput() { @@ -2471,17 +2257,22 @@ void HomeActivity::render(RenderLock&&) { return; } - // Fast path: pre-rendered frames ready — memcpy + border overlay + // Fast path: restore artwork and draw current progress and controls. if (carouselFramesReady) { uint8_t* frameBuffer = renderer.getFrameBuffer(); const int bookCount = static_cast(recentBooks.size()); const bool inCarouselRow = (selectorIndex < bookCount); const int centerIdx = inCarouselRow ? selectorIndex : lastCarouselBookIndex; int slotIdx = gCarouselCache.findFrameSlot(centerIdx); + bool saveViewedFrame = false; if (frameBuffer && slotIdx < 0 && gCarouselCache.keyHash != 0 && bookCount > 0) { const int evictSlot = chooseCarouselEvictionSlot(centerIdx, bookCount); - if (evictSlot >= 0 && loadCarouselFrameFromDisk(gCarouselCache.keyHash, bookCount, centerIdx, evictSlot)) { + if (evictSlot >= 0) { + if (!loadCarouselFrameFromDisk(gCarouselCache.keyHash, bookCount, centerIdx, evictSlot)) { + renderCarouselFrame(centerIdx, evictSlot); + saveViewedFrame = true; + } slotIdx = evictSlot; } } @@ -2490,31 +2281,27 @@ void HomeActivity::render(RenderLock&&) { memcpy(frameBuffer, carouselFrames[slotIdx], renderer.getBufferSize()); LyraCarouselTheme::setPreRenderIndex(centerIdx); - // Cached carousel frames include the header; redraw it so dynamic values - // like battery percentage and clock are current for every restored frame. + // The snapshot contains artwork only; all changing UI uses current state. + const Rect coverRect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}; + const auto& carouselTheme = static_cast(GUI); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr); - GUI.drawCarouselBorder(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, - recentBooks, centerIdx, inCarouselRow); + GUI.drawCarouselBorder(renderer, coverRect, recentBooks, centerIdx, inCarouselRow); + carouselTheme.drawReadingProgress(renderer, coverRect, recentBooks, + hasAnyBookStats(currentBookStats) ? ¤tBookStats : nullptr, + currentBookProgressPercent); const auto menuItems = buildHomeMenuItems(hasOpdsServers, hasReadingStats, hasBookmarks, hasClippings); - if (static_cast(SETTINGS.uiTheme) == CrossPointSettings::UI_THEME::LYRA_CAROUSEL) { - static_cast(GUI).registerButtonMenuTouchTargets(renderer, - static_cast(menuItems.size())); - } - if (static_cast(SETTINGS.uiTheme) == CrossPointSettings::UI_THEME::LYRA_CAROUSEL) { - const int menuHighlightIndex = mappedInput.hasTouchHardware() - ? carouselMenuTouchDownIndex - : (inCarouselRow ? -1 : selectorIndex - recentBooks.size()); - if (menuHighlightIndex >= 0) { - static_cast(GUI).drawButtonMenuSelectionOverlay( - renderer, static_cast(menuItems.size()), menuHighlightIndex, - [&menuItems](int index) { return menuItems[index].label; }, - [&menuItems](int index) { return menuItems[index].icon; }); - } - } + const int menuHighlightIndex = mappedInput.hasTouchHardware() + ? carouselMenuTouchDownIndex + : (inCarouselRow ? -1 : selectorIndex - static_cast(recentBooks.size())); + GUI.drawButtonMenu( + renderer, Rect{}, static_cast(menuItems.size()), menuHighlightIndex, + [&menuItems](int index) { return menuItems[index].label; }, + [&menuItems](int index) { return menuItems[index].icon; }); + const auto labels = mappedInput.mapLabels(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(); - // E-ink refresh complete — pre-render the missing adjacent frame while idle. - updateSlidingWindowCache(centerIdx, bookCount); + if (saveViewedFrame) saveCarouselFrameToDisk(gCarouselCache.keyHash, bookCount, centerIdx, slotIdx); // Mirror the slow-path trigger: generate missing thumbnails on the second // render so the E-ink is already showing something before the SD work starts. if (!firstRenderDone) { @@ -2598,8 +2385,8 @@ void HomeActivity::render(RenderLock&&) { // Resolve any missing cover thumbs first, then warm the carousel snapshot. // Cover generation needs more contiguous heap than the frame cache path. carouselWarmupPending = false; - const bool showedWarmupProgress = preRenderCarouselFrames(true); - if (carouselFramesReady || showedWarmupProgress) { + preRenderCarouselFrames(); + if (carouselFramesReady) { requestUpdate(); } } @@ -2612,20 +2399,13 @@ void HomeActivity::renderCarouselFrame(int bookIdx, int slotIdx) { } uint8_t* frameBuffer = renderer.getFrameBuffer(); if (!frameBuffer || !gCarouselCache.frames[slotIdx]) return; - renderCarouselFrameToCurrentBuffer(bookIdx, nullptr, nullptr, nullptr); + renderCarouselFrameToCurrentBuffer(bookIdx); memcpy(gCarouselCache.frames[slotIdx], frameBuffer, renderer.getBufferSize()); gCarouselCache.frameBookIdx[slotIdx] = bookIdx; carouselFrames[slotIdx] = gCarouselCache.frames[slotIdx]; } -void HomeActivity::updateSlidingWindowCache(int centerIdx, int bookCount) { - (void)centerIdx; - (void)bookCount; - // The current carousel cache keeps one frame in RAM; other frames are paged - // from the SD snapshot cache on demand in render(). -} - void HomeActivity::openBook(const std::string& path) { // renderCarouselFrame() uses the same static cache on the render task. Hold // its lock while invalidating so a book selection cannot free a destination diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 12b82008488..a5a28b6b013 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -90,6 +90,8 @@ class HomeActivity final : public Activity { bool carouselFramesReady = false; bool carouselFramesInverted = false; bool carouselWarmupPending = false; + uint8_t themeBeforeFrontlightPanel = 0; + uint8_t scaleBeforeFrontlightPanel = 0; std::vector recentBooks; const HomeMenuItem initialMenuItem; @@ -111,18 +113,15 @@ class HomeActivity final : public Activity { void freeCoverBuffer(); // Free the stored cover buffer void invalidateCoverCache(); void invalidatePolarityMismatchedCaches(); - bool preRenderCarouselFrames(bool showProgressPopup = false); + void preRenderCarouselFrames(); void freeCarouselFrames(); bool allocateCarouselFrameSlots(int targetFrameCount); - bool buildCarouselCacheFile(const std::string& cacheKey, uint64_t cacheKeyHash, int bookCount, - bool showProgressPopup = false); + bool saveCarouselFrameToDisk(uint64_t cacheKeyHash, int bookCount, int bookIdx, int slotIdx); bool loadCarouselFrameFromDisk(uint64_t cacheKeyHash, int bookCount, int bookIdx, int slotIdx); int chooseCarouselEvictionSlot(int centerIdx, int bookCount, std::optional protectedBookIdx = std::nullopt) const; - void renderCarouselFrameToCurrentBuffer(int bookIdx, BookReadingStats* outStats, float* outProgressPercent, - bool* outUsedCachedStats); + void renderCarouselFrameToCurrentBuffer(int bookIdx); void renderCarouselFrame(int bookIdx, int slotIdx); - void updateSlidingWindowCache(int centerIdx, int bookCount); int getHighlightedBookIndex() const; int getVisibleRecentBookCount() const; bool canSwapHomeBook() const; @@ -169,6 +168,7 @@ class HomeActivity final : public Activity { std::string getCurrentBookPath() const override; std::string getCurrentBookTitle() const override; std::unique_ptr createFrontlightReadingStatsActivity() override; + void onFrontlightPanelOpened() override; void onFrontlightPanelClosed() override; bool handleFrontlightPanelResult(const FrontlightPanelResult& result) override; void onUserInput() override; diff --git a/src/activities/library/LibraryInputBuffer.h b/src/activities/library/LibraryInputBuffer.h new file mode 100644 index 00000000000..b09fbaa82bd --- /dev/null +++ b/src/activities/library/LibraryInputBuffer.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include + +// Main-loop-owned events. Fixed storage keeps polling allocation-free while the +// render task owns the UI; FIFO order preserves navigation followed by Select. +class LibraryInputBuffer { + public: + enum class Type : uint8_t { + TouchPress, + TouchRelease, + TouchLongPress, + ConfirmLongPress, + ConfirmRelease, + BackRelease, + LeftRelease, + RightRelease, + Next, + Previous, + NextPage, + PreviousPage, + SwipeUp, + SwipeDown, + SwipeLeft, + SwipeRight + }; + struct Event { + Type type = Type::TouchRelease; + int16_t x = -1; + int16_t y = -1; + }; + static constexpr size_t CAPACITY = 32; + + bool push(const Event event) { + if (count == CAPACITY) return false; + events[(head + count) % CAPACITY] = event; + ++count; + return true; + } + bool pop(Event& event) { + if (!count) return false; + event = events[head]; + head = (head + 1) % CAPACITY; + --count; + return true; + } + size_t size() const { return count; } + void clear() { head = count = 0; } + + private: + std::array events{}; + uint8_t head = 0; + uint8_t count = 0; +}; diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 2842b896473..c652d4928a1 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -10,6 +10,7 @@ #include +#include "CrossPointState.h" #include "MappedInputManager.h" #include "NetworkModeSelectionActivity.h" #include "SdCardFontSystem.h" @@ -364,6 +365,12 @@ void CrossPointWebServerActivity::exitToOrigin() { return; } + // A portal rename updates the persisted resume path while this activity's + // original return path still contains the old filename. + if (!Storage.exists(returnBookPath.c_str()) && !APP_STATE.openEpubPath.empty() && + Storage.exists(APP_STATE.openEpubPath.c_str())) { + returnBookPath = APP_STATE.openEpubPath; + } activityManager.goToReader(returnBookPath, true); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index cb5de226bf5..765da1c9775 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2343,6 +2343,7 @@ void EpubReaderActivity::endGlobalSettingsEditForBookReader(void* ctx) { void EpubReaderActivity::onEnter() { Activity::onEnter(); pageLoadRetryCount = 0; + touchReaderDrawerState = initialReaderDrawerState(mappedInput.hasTouchHardware()); MemoryBudget::logEpubHeapPools("reader enter"); @@ -2723,7 +2724,8 @@ void EpubReaderActivity::openReaderMenu() { requestUpdate(); return; } - touchReaderDrawerState = menu->reopenDrawer ? menu->drawerState : ReaderDrawerState{}; + touchReaderDrawerState = + menu->reopenDrawer ? menu->drawerState : initialReaderDrawerState(mappedInput.hasTouchHardware()); applyOrientation(menu->orientation); if (menu->settingsChanged && hasReaderSettingsChange(menu->changeMask, ReaderSettingsChangeMask::Relayout)) { ensureReaderSdFontLoaded(renderer); diff --git a/src/activities/reader/EpubReaderDrawerActivity.cpp b/src/activities/reader/EpubReaderDrawerActivity.cpp index 297f16dde53..ce6decdf95a 100644 --- a/src/activities/reader/EpubReaderDrawerActivity.cpp +++ b/src/activities/reader/EpubReaderDrawerActivity.cpp @@ -619,6 +619,7 @@ void EpubReaderDrawerActivity::commitSettings() { // Save only an SD font whose preview already loaded and prewarmed. LOG_ERR("ERDM", "Selected SD font was not previewed; retaining previous reader font"); restoreReaderDraftFont(draft, lastGoodPreviewSettings); + state.pendingFontIndex = -1; } applySettings(draft); if (saveReaderSettingsCallback) { @@ -887,7 +888,7 @@ void EpubReaderDrawerActivity::buildDrawer(UiApp::ScreenType& screen) { const fui::Rect tabs = buttonDevice ? screen.takeTop(tabBarHeight) : screen.takeBottom(tabBarHeight); buildTabBar(screen, tabs, buttonDevice); samplePreviewBounds = {}; - // cppcheck-suppress knownConditionTrueFalse ; compile-time constant on builds without the sample preview +#if CROSSINK_APP_READER_SAMPLE_PREVIEW if (showsSamplePreview()) { const auto& metrics = UITheme::getInstance().getMetrics(); // In portrait, keep the sample at its pre-header height so the new book @@ -909,6 +910,7 @@ void EpubReaderDrawerActivity::buildDrawer(UiApp::ScreenType& screen) { samplePreviewBounds = screen.takeTop(static_cast(previewHeight), static_cast(metrics.verticalSpacing)); } +#endif screen.insetContent(fui::Insets{sheet.ruleWidth, DRAWER_SIDE_INSET, 0, DRAWER_SIDE_INSET}); switch (state.pane) { @@ -1381,24 +1383,7 @@ void EpubReaderDrawerActivity::buildDictionaryPane(UiApp::ScreenType& screen) { screen.theme().listScrollInset); } -void EpubReaderDrawerActivity::buildFontFamilyPane(UiApp::ScreenType& screen) { - buildPaneHeader(screen); - const int total = static_cast(fontLabels.size()); - const fui::Rect listBounds = screen.body(); - fui::ListProps props; - // This picker has no subtitles, so it does not need the default - // label-plus-subtitle row height that left a conspicuous blank band above - // the tab row when scrolling. - props.rowHeight = - !mappedInput.hasTouchHardware() - ? uiListRowHeight(screen.theme(), UiListRowType::SingleLine) - : std::max(screen.theme().minTouchSize, - static_cast(screen.theme().rowHeight - FONT_FAMILY_ROW_HEIGHT_REDUCTION)); - visibleRows = configureDrawerList(props, screen.theme(), listBounds); - const int top = std::clamp(state.paneTopIndex, 0, std::max(0, total - visibleRows)); - state.paneTopIndex = static_cast(top); - const int drawCount = std::min({visibleRows, WINDOW_SIZE, total - top}); - if (!CROSSINK_APP_READER_SAMPLE_PREVIEW) evenlySpaceDrawerListRows(props, listBounds, drawCount); +int EpubReaderDrawerActivity::currentFontSelectionIndex() const { int selectedFontIndex = state.pendingFontIndex; if (selectedFontIndex < 0) { if (draft.sdFontFamilyName[0] != '\0') { @@ -1417,6 +1402,28 @@ void EpubReaderDrawerActivity::buildFontFamilyPane(UiApp::ScreenType& screen) { } } } + return selectedFontIndex; +} + +void EpubReaderDrawerActivity::buildFontFamilyPane(UiApp::ScreenType& screen) { + buildPaneHeader(screen); + const int total = static_cast(fontLabels.size()); + const fui::Rect listBounds = screen.body(); + fui::ListProps props; + // This picker has no subtitles, so it does not need the default + // label-plus-subtitle row height that left a conspicuous blank band above + // the tab row when scrolling. + props.rowHeight = + !mappedInput.hasTouchHardware() + ? uiListRowHeight(screen.theme(), UiListRowType::SingleLine) + : std::max(screen.theme().minTouchSize, + static_cast(screen.theme().rowHeight - FONT_FAMILY_ROW_HEIGHT_REDUCTION)); + visibleRows = configureDrawerList(props, screen.theme(), listBounds); + const int top = std::clamp(state.paneTopIndex, 0, std::max(0, total - visibleRows)); + state.paneTopIndex = static_cast(top); + const int drawCount = std::min({visibleRows, WINDOW_SIZE, total - top}); + if (!CROSSINK_APP_READER_SAMPLE_PREVIEW) evenlySpaceDrawerListRows(props, listBounds, drawCount); + const int selectedFontIndex = currentFontSelectionIndex(); for (int i = 0; i < drawCount; ++i) { itemWindow[static_cast(i)] = fui::ListItem{}; itemWindow[static_cast(i)].label = fontLabels[static_cast(top + i)].c_str(); @@ -1508,6 +1515,12 @@ void EpubReaderDrawerActivity::openPane(const ReaderDrawerPane pane) { state.paneTopIndex = 0; state.selectedIndex = 0; buttonSliderState = {}; + if (pane == ReaderDrawerPane::FontFamily) { + const int currentIndex = currentFontSelectionIndex(); + state.selectedIndex = static_cast(std::max(0, currentIndex)); + state.paneTopIndex = state.selectedIndex; + buttonFocusActive = currentIndex >= 0; + } if (pane == ReaderDrawerPane::Percent || pane == ReaderDrawerPane::StablePage) { percentKeypadActive = false; percentConfirmLongPressFired = false; @@ -1992,8 +2005,9 @@ void EpubReaderDrawerActivity::openEnumOptions(const RowId row, const StrId titl previewedEnumOptionIndex = -1; enumOptionReturnPane = state.pane; state.pane = ReaderDrawerPane::EnumOptions; - state.paneTopIndex = 0; - state.selectedIndex = 0; + state.selectedIndex = enumOptionSelectedIndex; + state.paneTopIndex = state.selectedIndex; + buttonFocusActive = true; requestUpdate(); } @@ -2364,7 +2378,8 @@ void EpubReaderDrawerActivity::moveSelection(const bool forward, const bool page count = static_cast(activeRows().size()); } if (count <= 0) return; - if (!mappedInput.hasTouchHardware() && state.pane == ReaderDrawerPane::Root && !buttonFocusActive) { + if (!mappedInput.hasTouchHardware() && !buttonFocusActive && + (state.pane == ReaderDrawerPane::Root || state.pane == ReaderDrawerPane::FontFamily)) { state.selectedIndex = forward ? 0 : static_cast(count - 1); } else { state.selectedIndex = page ? (forward ? ButtonNavigator::nextPageIndex(state.selectedIndex, count, visibleRows) @@ -2527,8 +2542,9 @@ void EpubReaderDrawerActivity::renderPreviewUnavailable() { bool EpubReaderDrawerActivity::renderPreview(int& previewFontId, std::optional& prewarmScope) { previewFontId = -1; - // cppcheck-suppress knownConditionTrueFalse ; compile-time constant on builds without the sample preview - if (CROSSINK_APP_READER_SAMPLE_PREVIEW && !showsSamplePreview()) return false; +#if CROSSINK_APP_READER_SAMPLE_PREVIEW + if (!showsSamplePreview()) return false; +#endif if (!previewDirty) return false; previewDirty = false; const auto releasePreviewIfBelowReserve = [this, &previewFontId] { @@ -2540,6 +2556,7 @@ bool EpubReaderDrawerActivity::renderPreview(int& previewFontId, LOG_ERR("ERDM", "Button preview exhausted EPUB layout reserve: free=%u maxAlloc=%u", heap.freeHeap, heap.maxAllocHeap); restoreReaderDraftFont(draft, lastGoodPreviewSettings); + state.pendingFontIndex = -1; previewUnavailable = true; previewFontId = -1; renderPreviewUnavailable(); @@ -2572,6 +2589,7 @@ bool EpubReaderDrawerActivity::renderPreview(int& previewFontId, if (!fontLoaded && ownedPreviewModel) { LOG_ERR("ERDM", "Could not load selected SD font for button preview"); restoreReaderDraftFont(draft, lastGoodPreviewSettings); + state.pendingFontIndex = -1; previewUnavailable = true; previewFontId = -1; renderPreviewUnavailable(); @@ -2584,6 +2602,7 @@ bool EpubReaderDrawerActivity::renderPreview(int& previewFontId, if (!prewarmScope->endScanAndPrewarm() && ownedPreviewModel) { LOG_ERR("ERDM", "Could not prewarm selected font for button preview"); restoreReaderDraftFont(draft, lastGoodPreviewSettings); + state.pendingFontIndex = -1; previewUnavailable = true; previewFontId = -1; renderPreviewUnavailable(); @@ -2780,10 +2799,20 @@ void EpubReaderDrawerActivity::loop() { } return; } - buttonNavigator.onNextRelease([this] { moveSelection(true, false); }); - buttonNavigator.onPreviousRelease([this] { moveSelection(false, false); }); - buttonNavigator.onNextContinuous([this] { moveSelection(true, true); }); - buttonNavigator.onPreviousContinuous([this] { moveSelection(false, true); }); + const auto left = mappedInput.menuButton(MappedInputManager::Button::Left); + const auto right = mappedInput.menuButton(MappedInputManager::Button::Right); + const auto up = mappedInput.menuButton(MappedInputManager::Button::Up); + const auto down = mappedInput.menuButton(MappedInputManager::Button::Down); + buttonNavigator.onRelease({down, down}, [this] { moveSelection(true, false); }); + buttonNavigator.onRelease({up, up}, [this] { moveSelection(false, false); }); + buttonNavigator.onContinuous({down, down}, [this] { moveSelection(true, true); }); + buttonNavigator.onContinuous({up, up}, [this] { moveSelection(false, true); }); + if (state.pane == ReaderDrawerPane::Root) { + buttonNavigator.onRelease({right, right}, [this] { changeTab(adjacentReaderDrawerTab(state.tab, true)); }); + buttonNavigator.onRelease({left, left}, [this] { changeTab(adjacentReaderDrawerTab(state.tab, false)); }); + buttonNavigator.onContinuous({right, right}, [this] { changeTab(adjacentReaderDrawerTab(state.tab, true)); }); + buttonNavigator.onContinuous({left, left}, [this] { changeTab(adjacentReaderDrawerTab(state.tab, false)); }); + } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (!mappedInput.hasTouchHardware() && state.pane == ReaderDrawerPane::Root && !buttonFocusActive) { changeTab(adjacentReaderDrawerTab(state.tab, true)); @@ -2823,7 +2852,10 @@ void EpubReaderDrawerActivity::render(RenderLock&&) { int previewFontId = -1; // Keep prewarmed glyphs resident through the BW and optional touch grayscale passes. std::optional previewPrewarmScope; - bool previewRendered = !CROSSINK_APP_READER_SAMPLE_PREVIEW && renderPreview(previewFontId, previewPrewarmScope); + bool previewRendered; +#if !CROSSINK_APP_READER_SAMPLE_PREVIEW + previewRendered = renderPreview(previewFontId, previewPrewarmScope); +#endif uiReady = false; if (CROSSINK_APP_READER_SAMPLE_PREVIEW && fontPreviewLoading) { GUI.drawPopup(renderer, tr(STR_LOADING_POPUP), true); @@ -2832,16 +2864,15 @@ void EpubReaderDrawerActivity::render(RenderLock&&) { app.setDevice(uiTarget.deviceContext()); app.render(); if (buttonDevice) drawButtonBookHeader(); - if (CROSSINK_APP_READER_SAMPLE_PREVIEW) { - previewDirty = true; // The full-screen UI cleared the sample area as well. - previewRendered = renderPreview(previewFontId, previewPrewarmScope); - // cppcheck-suppress knownConditionTrueFalse ; compile-time constant on builds without the sample preview - if (showsSamplePreview() && previewUnavailable) { - app.render(); // A failed font selection rolled the draft back; repaint its values too. - drawButtonBookHeader(); - renderPreviewUnavailable(); - } +#if CROSSINK_APP_READER_SAMPLE_PREVIEW + previewDirty = true; // The full-screen UI cleared the sample area as well. + previewRendered = renderPreview(previewFontId, previewPrewarmScope); + if (showsSamplePreview() && previewUnavailable) { + app.render(); // A failed font selection rolled the draft back; repaint its values too. + drawButtonBookHeader(); + renderPreviewUnavailable(); } +#endif uiReady = true; if (!mappedInput.hasTouchHardware()) { const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false); @@ -2861,8 +2892,12 @@ void EpubReaderDrawerActivity::render(RenderLock&&) { const bool fineAdjustment = (state.pane == ReaderDrawerPane::Percent && !percentKeypadActive) || state.pane == ReaderDrawerPane::AutoPageTurn || (readerDrawerStepChangesSettings(state.pane) && buttonSliderState.editing); - const char* previousLabel = tr(STR_DIR_UP); - const char* nextLabel = tr(STR_DIR_DOWN); + const bool menuNavigation = state.pane != ReaderDrawerPane::Percent && + state.pane != ReaderDrawerPane::AutoPageTurn && + !readerDrawerStepChangesSettings(state.pane); + const bool horizontalFront = menuNavigation && !deviceUsesHorizontalSideButtonsForMenus(gpio); + const char* previousLabel = horizontalFront ? tr(STR_DIR_LEFT) : tr(STR_DIR_UP); + const char* nextLabel = horizontalFront ? tr(STR_DIR_RIGHT) : tr(STR_DIR_DOWN); if (state.pane == ReaderDrawerPane::Percent && percentKeypadActive) { previousLabel = tr(STR_DIR_LEFT); nextLabel = tr(STR_DIR_RIGHT); diff --git a/src/activities/reader/EpubReaderDrawerActivity.h b/src/activities/reader/EpubReaderDrawerActivity.h index a40b7ac10a0..f524a359a58 100644 --- a/src/activities/reader/EpubReaderDrawerActivity.h +++ b/src/activities/reader/EpubReaderDrawerActivity.h @@ -42,9 +42,17 @@ class EpubReaderDrawerActivity final : public Activity { void* endGlobalSettingsEditContext = nullptr, const char* dictionaryFontFamilyName = nullptr, uint8_t dictionaryFontPointSize = 0, bool hasDictionaryFontOverride = false, ReaderOptionsActivity::DictionaryFontChangedCallback dictionaryFontChangedCallback = nullptr, - void* dictionaryFontChangedContext = nullptr, const ReaderDrawerState& initialState = {}, + void* dictionaryFontChangedContext = nullptr, + const ReaderDrawerState& initialState = initialReaderDrawerState(CROSSINK_APP_CAP_TOUCH), std::unique_ptr ownedPreviewModel = nullptr); +#ifdef SIMULATOR + const ReaderDrawerState& simulatorState() const { return state; } + bool simulatorFocusedRowVisible() const { + return buttonFocusActive && state.selectedIndex >= activeTopIndex() && + state.selectedIndex < activeTopIndex() + visibleRows; + } +#endif void onEnter() override; void onExit() override; void loop() override; @@ -215,6 +223,7 @@ class EpubReaderDrawerActivity final : public Activity { void buildAutoPageTurnPane(UiApp::ScreenType& screen); void buildConfirmButton(UiApp::ScreenType& screen); void buildDictionaryPane(UiApp::ScreenType& screen); + int currentFontSelectionIndex() const; void buildFontFamilyPane(UiApp::ScreenType& screen); void buildEnumOptionsPane(UiApp::ScreenType& screen); void buildTtfRenderingPane(UiApp::ScreenType& screen); diff --git a/src/activities/reader/EpubReaderMenuModel.h b/src/activities/reader/EpubReaderMenuModel.h index 246e3da951f..d191462cca4 100644 --- a/src/activities/reader/EpubReaderMenuModel.h +++ b/src/activities/reader/EpubReaderMenuModel.h @@ -303,6 +303,12 @@ struct ReaderDrawerState { int16_t pendingFontIndex = -1; }; +inline ReaderDrawerState initialReaderDrawerState(const bool hasTouchHardware) { + ReaderDrawerState state; + state.tab = hasTouchHardware ? ReaderDrawerTab::Font : ReaderDrawerTab::More; + return state; +} + inline void restoreReaderDrawerScroll(ReaderDrawerState& state, const int16_t scrollPosition) { if (state.pane == ReaderDrawerPane::Root) { state.rootTopIndex[static_cast(state.tab)] = scrollPosition; diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index d0a9505162e..127a9aee1ff 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -35,50 +35,115 @@ #include "network/WifiUtils.h" namespace { -constexpr int RESULT_LOCAL_PAGE_Y_OFFSET = 200; -constexpr int RESULT_ACTION_MARGIN_TOP = 20; -constexpr int RESULT_ACTION_HEIGHT = 48; -constexpr int RESULT_ACTION_GAP = 10; -constexpr int RESULT_NON_TOUCH_ACTION_MARGIN_TOP = 8; constexpr int RESULT_NON_TOUCH_ACTION_HEIGHT = 40; constexpr int RESULT_NON_TOUCH_ACTION_GAP = 8; +constexpr int RESULT_CARD_PADDING = 12; +constexpr int RESULT_CARD_ROW_GAP = 6; +constexpr int RESULT_CARD_BAR_HEIGHT = 10; +constexpr int RESULT_BADGE_PADDING_X = 6; + +// Progress in tenths of a percent, matching the card's "%.1f%%" label so the "Ahead" badge never +// disagrees with the numbers shown on screen. +long displayedTenths(const float percentage) { return std::lround(percentage * 1000.0f); } + +// Apply/Upload buttons are pinned above the button hints (or the bottom edge on touch devices) so the +// progress cards can use the space above them. Rendering and hit testing share this layout. +TouchActionButtons::Layout resultActionLayout(const Rect& screen, const ThemeMetrics& metrics, const bool hasTouch) { + constexpr uint8_t buttonCount = 2; + const int buttonHeight = hasTouch ? TouchActionButtons::kDefaultHeight : RESULT_NON_TOUCH_ACTION_HEIGHT; + const int buttonGap = hasTouch ? TouchActionButtons::kDefaultGap : RESULT_NON_TOUCH_ACTION_GAP; + const int reservedBottom = hasTouch ? metrics.verticalSpacing : metrics.buttonHintsHeight + metrics.verticalSpacing; + const int totalHeight = buttonHeight * buttonCount + buttonGap * (buttonCount - 1); + const Rect container{screen.x + metrics.contentSidePadding, screen.y + screen.height - reservedBottom - totalHeight, + std::max(1, screen.width - metrics.contentSidePadding * 2), totalHeight}; + return TouchActionButtons::vertical(container, buttonCount, buttonHeight, buttonGap); +} -struct ResultActionLayout { - Rect buttons[2]; - int rowStep; - int rowHeight; - TouchActionButtons::Layout touchLayout; +struct ProgressCard { + const char* title; + const char* source; // Optional, e.g. the remote device name. + const char* pageText; + const char* chapter; + float percentage; // 0.0 - 1.0 + bool ahead; }; -ResultActionLayout resultActionLayout(const Rect& screen, const ThemeMetrics& metrics, const int contentTop, - const int lineHeight, const bool hasTouch) { - const int buttonX = screen.x + metrics.contentSidePadding; - const int buttonWidth = std::max(1, screen.width - metrics.contentSidePadding * 2); - const int buttonHeight = hasTouch ? RESULT_ACTION_HEIGHT : RESULT_NON_TOUCH_ACTION_HEIGHT; - const int buttonGap = hasTouch ? RESULT_ACTION_GAP : RESULT_NON_TOUCH_ACTION_GAP; - const int marginTop = hasTouch ? RESULT_ACTION_MARGIN_TOP : RESULT_NON_TOUCH_ACTION_MARGIN_TOP; - const int desiredButtonY = contentTop + RESULT_LOCAL_PAGE_Y_OFFSET + lineHeight + marginTop; - const int reservedBottom = hasTouch ? metrics.verticalSpacing : metrics.buttonHintsHeight + metrics.verticalSpacing; - const int latestButtonY = screen.y + screen.height - reservedBottom - buttonHeight * 2 - buttonGap; - const int firstButtonY = std::min(desiredButtonY, latestButtonY); - ResultActionLayout result{{Rect{buttonX, firstButtonY, buttonWidth, buttonHeight}, - Rect{buttonX, firstButtonY + buttonHeight + buttonGap, buttonWidth, buttonHeight}}, - buttonHeight + buttonGap, - buttonHeight, - {}}; - if (hasTouch) { - constexpr int touchHeight = TouchActionButtons::kDefaultHeight; - constexpr int touchGap = TouchActionButtons::kDefaultGap; - const int touchTotal = touchHeight * 2 + touchGap; - const Rect touchContainer{buttonX, std::min(firstButtonY, screen.y + screen.height - reservedBottom - touchTotal), - buttonWidth, touchTotal}; - result.touchLayout = TouchActionButtons::vertical(touchContainer, 2); - result.buttons[0] = result.touchLayout.buttons[0]; - result.buttons[1] = result.touchLayout.buttons[1]; - result.rowStep = touchHeight + touchGap; - result.rowHeight = touchHeight; - } - return result; +int progressCardHeight(const GfxRenderer& renderer) { + return RESULT_CARD_PADDING * 2 + renderer.getLineHeight(UI_10_FONT_ID) + RESULT_CARD_ROW_GAP + + renderer.getLineHeight(UI_12_FONT_ID) + RESULT_CARD_ROW_GAP + RESULT_CARD_BAR_HEIGHT + RESULT_CARD_ROW_GAP + + renderer.getLineHeight(UI_10_FONT_ID); +} + +// Draws text so its baseline lines up with a larger font drawn at rowY. +void drawTextOnBaseline(const GfxRenderer& renderer, const int fontId, const int x, const int rowY, const int rowFontId, + const char* text, const bool black = true, + const EpdFontFamily::Style style = EpdFontFamily::REGULAR) { + const int y = rowY + renderer.getFontAscenderSize(rowFontId) - renderer.getFontAscenderSize(fontId); + renderer.drawText(fontId, x, y, text, black, style); +} + +// Card layout: +// Title source [Ahead] +// 42.5% Page 3/12 +// [========= ] +// Chapter name... +void drawProgressCard(const GfxRenderer& renderer, const Rect& card, const ProgressCard& info) { + renderer.drawRect(card.x, card.y, card.width, card.height, true); + + const int x = card.x + RESULT_CARD_PADDING; + const int innerWidth = std::max(1, card.width - RESULT_CARD_PADDING * 2); + const int right = x + innerWidth; + const int smallGap = RESULT_CARD_ROW_GAP * 2; + int y = card.y + RESULT_CARD_PADDING; + + // Row 1: title, optional source, optional "Ahead" badge. + const int titleLineHeight = renderer.getLineHeight(UI_10_FONT_ID); + int titleRight = right; + if (info.ahead) { + const char* badge = tr(STR_SYNC_AHEAD); + const int badgeWidth = renderer.getTextWidth(SMALL_FONT_ID, badge) + RESULT_BADGE_PADDING_X * 2; + const int badgeX = right - badgeWidth; + renderer.fillRect(badgeX, y, badgeWidth, titleLineHeight, true); + const int badgeTextY = y + (titleLineHeight - renderer.getLineHeight(SMALL_FONT_ID)) / 2; + renderer.drawText(SMALL_FONT_ID, badgeX + RESULT_BADGE_PADDING_X, badgeTextY, badge, false); + titleRight = badgeX - smallGap; + } + const std::string title = + renderer.truncatedText(UI_10_FONT_ID, info.title, std::max(1, titleRight - x), EpdFontFamily::BOLD); + renderer.drawText(UI_10_FONT_ID, x, y, title.c_str(), true, EpdFontFamily::BOLD); + if (info.source != nullptr && info.source[0] != '\0') { + const int sourceX = x + renderer.getTextWidth(UI_10_FONT_ID, title.c_str(), EpdFontFamily::BOLD) + smallGap; + if (titleRight - sourceX > 0) { + const std::string source = renderer.truncatedText(SMALL_FONT_ID, info.source, titleRight - sourceX); + drawTextOnBaseline(renderer, SMALL_FONT_ID, sourceX, y, UI_10_FONT_ID, source.c_str()); + } + } + y += titleLineHeight + RESULT_CARD_ROW_GAP; + + // Row 2: large percentage with the page position right-aligned on the same baseline. + char percentStr[16]; + snprintf(percentStr, sizeof(percentStr), "%.1f%%", info.percentage * 100); + renderer.drawText(UI_12_FONT_ID, x, y, percentStr, true, EpdFontFamily::BOLD); + const int percentRight = x + renderer.getTextWidth(UI_12_FONT_ID, percentStr, EpdFontFamily::BOLD) + smallGap; + if (right - percentRight > 0) { + const std::string page = renderer.truncatedText(UI_10_FONT_ID, info.pageText, right - percentRight); + const int pageX = right - renderer.getTextWidth(UI_10_FONT_ID, page.c_str()); + drawTextOnBaseline(renderer, UI_10_FONT_ID, pageX, y, UI_12_FONT_ID, page.c_str()); + } + y += renderer.getLineHeight(UI_12_FONT_ID) + RESULT_CARD_ROW_GAP; + + // Row 3: progress bar. + renderer.drawRect(x, y, innerWidth, RESULT_CARD_BAR_HEIGHT, true); + const float clamped = std::min(1.0f, std::max(0.0f, info.percentage)); + const int fillWidth = static_cast((innerWidth - 4) * clamped); + if (fillWidth > 0) { + renderer.fillRect(x + 2, y + 2, fillWidth, RESULT_CARD_BAR_HEIGHT - 4, true); + } + y += RESULT_CARD_BAR_HEIGHT + RESULT_CARD_ROW_GAP; + + // Row 4: chapter name. + const std::string chapter = renderer.truncatedText(UI_10_FONT_ID, info.chapter, innerWidth); + renderer.drawText(UI_10_FONT_ID, x, y, chapter.c_str()); } TouchActionButtons::Layout noRemoteProgressActionLayout(const Rect& screen, const ThemeMetrics& metrics) { @@ -691,9 +756,10 @@ void KOReaderSyncActivity::render(RenderLock&&) { } if (state == SHOWING_RESULT) { - // Show comparison + const bool hasTouch = mappedInput.hasTouchHardware(); top = screen.y + metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + metrics.verticalSpacing; renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_PROGRESS_FOUND), true, EpdFontFamily::BOLD); + top += renderer.getLineHeight(UI_10_FONT_ID) + metrics.verticalSpacing; // Remote chapter name requires Epub (loaded lazily in performSync before this state). const int remoteTocIndex = epub->getTocIndexForSpineIndex(remotePosition.spineIndex); @@ -705,53 +771,38 @@ void KOReaderSyncActivity::render(RenderLock&&) { !localChapterName.empty() ? localChapterName : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(currentSpineIndex + 1)); - // Remote progress - chapter and page - renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 40, tr(STR_REMOTE_LABEL), true); - char remoteChapterStr[128]; - snprintf(remoteChapterStr, sizeof(remoteChapterStr), " %s", remoteChapter.c_str()); - renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 65, remoteChapterStr); - char remotePageStr[64]; - snprintf(remotePageStr, sizeof(remotePageStr), tr(STR_PAGE_OVERALL_FORMAT), remotePosition.pageNumber + 1, - remoteProgress.percentage * 100); - renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 90, remotePageStr); - - if (!remoteProgress.device.empty()) { - char deviceStr[64]; - snprintf(deviceStr, sizeof(deviceStr), tr(STR_DEVICE_FROM_FORMAT), remoteProgress.device.c_str()); - renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 115, deviceStr); - } - - // Local progress - chapter and page - renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 150, tr(STR_LOCAL_LABEL), true); - char localChapterStr[128]; - snprintf(localChapterStr, sizeof(localChapterStr), " %s", localChapter.c_str()); - renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 175, localChapterStr); - char localPageStr[64]; - snprintf(localPageStr, sizeof(localPageStr), tr(STR_PAGE_TOTAL_OVERALL_FORMAT), currentPage + 1, totalPagesInSpine, - localProgress.percentage * 100); - renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + RESULT_LOCAL_PAGE_Y_OFFSET, - localPageStr); - - const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); - const auto actions = resultActionLayout(screen, metrics, top, lineHeight, mappedInput.hasTouchHardware()); - const char* actionLabels[] = {tr(STR_APPLY_REMOTE), tr(STR_UPLOAD_LOCAL)}; - if (mappedInput.hasTouchHardware()) { - TouchActionButtons::draw(renderer, actions.touchLayout, actionLabels, selectedOption, selectedOption, - UI_10_FONT_ID); + char remotePageStr[48]; + snprintf(remotePageStr, sizeof(remotePageStr), tr(STR_SYNC_PAGE_FORMAT), remotePosition.pageNumber + 1); + char localPageStr[48]; + snprintf(localPageStr, sizeof(localPageStr), tr(STR_SYNC_PAGE_TOTAL_FORMAT), currentPage + 1, totalPagesInSpine); + + const long remoteTenths = displayedTenths(remoteProgress.percentage); + const long localTenths = displayedTenths(localProgress.percentage); + const ProgressCard cards[] = { + {tr(STR_SYNC_REMOTE_TITLE), remoteProgress.device.c_str(), remotePageStr, remoteChapter.c_str(), + remoteProgress.percentage, remoteTenths > localTenths}, + {tr(STR_SYNC_LOCAL_TITLE), nullptr, localPageStr, localChapter.c_str(), localProgress.percentage, + localTenths > remoteTenths}, + }; + + // Stack the cards in portrait; place them side by side in landscape where height is scarce. + const int contentX = screen.x + metrics.contentSidePadding; + const int contentWidth = std::max(1, screen.width - metrics.contentSidePadding * 2); + const int cardHeight = progressCardHeight(renderer); + const int cardGap = metrics.verticalSpacing; + if (screen.width > screen.height) { + const int cardWidth = std::max(1, (contentWidth - cardGap) / 2); + drawProgressCard(renderer, Rect{contentX, top, cardWidth, cardHeight}, cards[0]); + drawProgressCard(renderer, Rect{contentX + contentWidth - cardWidth, top, cardWidth, cardHeight}, cards[1]); } else { - for (int option = 0; option < 2; ++option) { - const Rect& button = actions.buttons[option]; - const bool selected = selectedOption == option; - if (selected) { - renderer.fillRect(button.x, button.y, button.width, button.height); - } - renderer.drawRect(button.x, button.y, button.width, button.height, true); - const int textX = button.x + (button.width - renderer.getTextWidth(UI_10_FONT_ID, actionLabels[option])) / 2; - const int textY = button.y + (button.height - lineHeight) / 2; - renderer.drawText(UI_10_FONT_ID, textX, textY, actionLabels[option], !selected); - } + drawProgressCard(renderer, Rect{contentX, top, contentWidth, cardHeight}, cards[0]); + drawProgressCard(renderer, Rect{contentX, top + cardHeight + cardGap, contentWidth, cardHeight}, cards[1]); } + const auto actions = resultActionLayout(screen, metrics, hasTouch); + const char* actionLabels[] = {tr(STR_APPLY_REMOTE), tr(STR_UPLOAD_LOCAL)}; + TouchActionButtons::draw(renderer, actions, actionLabels, selectedOption, selectedOption, UI_10_FONT_ID); + // Bottom button hints const auto labels = mappedInput.mapLabels(mappedInput.withBackArrow(tr(STR_BACK)), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); @@ -838,16 +889,11 @@ void KOReaderSyncActivity::loop() { }; { - const auto& metrics = UITheme::getInstance().getMetrics(); - const Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false); - const int top = - screen.y + metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + metrics.verticalSpacing; - const auto actions = resultActionLayout(screen, metrics, top, renderer.getLineHeight(UI_10_FONT_ID), - mappedInput.hasTouchHardware()); + const auto actions = resultActionLayout(screen, metrics, mappedInput.hasTouchHardware()); + const Rect& first = actions.buttons[0]; int touchedOption = -1; - const auto touch = - mappedInput.rowTouch(touchedOption, actions.buttons[0].y, actions.rowStep, 2, actions.buttons[0].x, - actions.buttons[0].x + actions.buttons[0].width, actions.rowHeight); + const auto touch = mappedInput.rowTouch(touchedOption, first.y, actions.buttons[1].y - first.y, actions.count, + first.x, first.x + first.width, first.height); if (touch == MappedInputManager::RowTouch::Down) { if (selectedOption != touchedOption) { selectedOption = touchedOption; diff --git a/src/activities/reader/TouchReaderPreviewModel.h b/src/activities/reader/TouchReaderPreviewModel.h index d37dfccbdd7..e4978cdf2a7 100644 --- a/src/activities/reader/TouchReaderPreviewModel.h +++ b/src/activities/reader/TouchReaderPreviewModel.h @@ -145,6 +145,21 @@ class ReaderPreviewModel { const uint8_t paragraphAlignment, const bool focusReadingEnabled, const bool guideReadingEnabled, const bool foregroundBlack, const int bottom = std::numeric_limits::max()) const { if (!valid()) return; + if constexpr (!KeepSourceBlocks) { + if (renderer.isFontCacheScanning()) { + // A newly selected SD font has no glyph metrics yet. Scan the whole + // bounded sample so changed line breaks cannot expose unprepared glyphs. + for (uint16_t index = 0; index < wordCount; ++index) { + drawWord(renderer, fontId, xOffset, yOffset, words[index], focusReadingEnabled, foregroundBlack); + } + // Spaces are measured between words even though they are not drawn. + renderer.drawText(fontId, xOffset, yOffset, " ", foregroundBlack, EpdFontFamily::REGULAR); + if (guideReadingEnabled) { + renderer.drawText(fontId, xOffset, yOffset, GUIDE_DOT_UTF8, foregroundBlack, EpdFontFamily::REGULAR); + } + return; + } + } const int currentLineHeight = std::max(1, (renderer.getLineHeight(fontId) * lineHeightPercent + 50) / 100); int y = firstLineY + yOffset; for (size_t paragraphStart = 0; paragraphStart < lineCount;) { @@ -574,10 +589,13 @@ class ReaderPreviewModel { // Deliberately fixed Latin sample: font/layout test content, not a UI label. inline constexpr char READER_PREVIEW_PARAGRAPH[] = - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore " - "magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo " - "consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. " - "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."; + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In malesuada congue tortor, vel egestas libero dignissim " + "mollis. " + "Duis ac massa id elit molestie cursus et vitae justo. Curabitur vel quam gravida, dapibus purus non, vulputate " + "nisi. " + "In sodales nisi vitae eleifend egestas. Nam pretium, ex eu iaculis porta, lorem lectus fermentum neque, id " + "sagittis risus enim in est. " + "Proin maximus fermentum ipsum nec feugiat. Cras pulvinar gravida leo fermentum rutrum."; using SampleReaderPreviewModel = ReaderPreviewModel; static_assert(sizeof(SampleReaderPreviewModel) <= 3U * 1024U, "Sample preview exceeds its C3 budget"); diff --git a/src/activities/reader/XtcReaderMenuActivity.cpp b/src/activities/reader/XtcReaderMenuActivity.cpp index 9e7c36eb562..18c40e30dae 100644 --- a/src/activities/reader/XtcReaderMenuActivity.cpp +++ b/src/activities/reader/XtcReaderMenuActivity.cpp @@ -187,9 +187,11 @@ void XtcReaderMenuActivity::refreshListItems() { listItems[index].label = I18N.get(items[index].labelId); 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); + listItems[index].toggle = true; + listItems[index].toggleChecked = SETTINGS.disableReaderTouchscreen != 0; } else if (items[index].action == MenuAction::TOGGLE_BOOK_STATS_TRACKING) { - listItems[index].value = I18N.get(bookStatsEnabled ? StrId::STR_ON : StrId::STR_OFF); + listItems[index].toggle = true; + listItems[index].toggleChecked = bookStatsEnabled; } } } diff --git a/src/activities/settings/FontSelectionActivity.cpp b/src/activities/settings/FontSelectionActivity.cpp index c4930775532..c97983b4112 100644 --- a/src/activities/settings/FontSelectionActivity.cpp +++ b/src/activities/settings/FontSelectionActivity.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -13,6 +14,7 @@ #include "CrossPointState.h" #include "MappedInputManager.h" #include "SdCardFontSystem.h" +#include "activities/reader/TouchReaderPreviewModel.h" #include "components/TouchHeaderBackButton.h" #include "components/UITheme.h" #include "components/UIThemeTokens.h" @@ -300,11 +302,14 @@ void FontSelectionActivity::renderPreviewPane(int top, int height, int fontId, c const int labelFontId = UI_10_FONT_ID; const int labelH = renderer.getTextHeight(labelFontId); const int labelGap = 4; - const int labelReserved = labelH + labelGap + metrics_.previewPadding; + const int labelReserved = labelH + 2 * labelGap + metrics_.previewPadding; char labelBuf[128]; snprintf(labelBuf, sizeof(labelBuf), "%s \"%s\"", tr(STR_PREVIEW), fontName ? fontName : ""); - const int labelY = top + height - metrics_.previewPadding - labelH; + const int dividerY = top + height - metrics_.previewPadding - labelH - labelGap; + const int bottomDividerY = top + height + metrics_.verticalSpacing / 2; + const int labelY = dividerY + (bottomDividerY - dividerY - labelH) / 2; + renderer.drawLine(0, dividerY, renderer.getScreenWidth() - 1, dividerY); renderer.drawText(labelFontId, left, labelY, labelBuf); if (fontId == 0) return; @@ -315,22 +320,69 @@ void FontSelectionActivity::renderPreviewPane(int top, int height, int fontId, c const int innerHeight = height - metrics_.previewPadding - labelReserved; const int maxLines = std::max(1, innerHeight / (lineH + 2)); - const char* previewText = I18N.get(StrId::STR_FONT_PREVIEW_TEXT); + const char* previewText = I18N.getLanguage() == Language::EN ? "" : tr(STR_FONT_PREVIEW_TEXT); if (auto* fcm = renderer.getFontCacheManager()) { - char prewarmBuf[256]; - snprintf(prewarmBuf, sizeof(prewarmBuf), "%s %s", previewText, ELLIPSIS_UTF8); - fcm->prewarmCache(fontId, prewarmBuf, 0x01); + // Prepare both samples together: SD font prewarming replaces the active + // glyph set. This exceeds the stack budget; size the temporary heap buffer + // to preserve every translated UTF-8 character. + const size_t prewarmSize = + sizeof(READER_PREVIEW_PARAGRAPH) + std::strlen(previewText) + std::strlen(ELLIPSIS_UTF8) + 2; + auto prewarmText = makeUniqueNoThrow(prewarmSize); + if (!prewarmText) { + LOG_ERR("FONT", "Cannot allocate font preview text (%u bytes)", static_cast(prewarmSize)); + } else { + snprintf(prewarmText.get(), prewarmSize, "%s %s %s", READER_PREVIEW_PARAGRAPH, previewText, ELLIPSIS_UTF8); + if (!fcm->prewarmCache(fontId, prewarmText.get(), 0x01)) { + LOG_ERR("FONT", "Cannot prepare font preview glyphs"); + } + } } - const auto lines = renderer.wrappedText(fontId, previewText, width, maxLines); - - int y = top + metrics_.previewPadding; - const int textBottomLimit = top + height - labelReserved; - for (const auto& line : lines) { - if (y + lineH > textBottomLimit) break; + // The pane starts after the header gap; include that gap when centering + // between the visible header and preview-label dividers. + const int textAreaTop = top - metrics_.verticalSpacing; + const int textAreaHeight = dividerY - textAreaTop; + if (textAreaHeight <= 0) return; + // English uses the regular paragraph throughout. Other languages retain + // room for their localized glyph sample, even when only one line fits. + const int sampleLines = *previewText ? std::min(2, maxLines - 1) : maxLines; + const auto loremLines = renderer.wrappedText(fontId, READER_PREVIEW_PARAGRAPH, width, sampleLines); + const int remainingLines = maxLines - static_cast(loremLines.size()); + const auto localizedLines = *previewText && remainingLines > 0 + ? renderer.wrappedText(fontId, previewText, width, remainingLines) + : std::vector{}; + const int renderedLineCount = static_cast(loremLines.size() + localizedLines.size()); + if (renderedLineCount == 0) return; + + int blockTop = 0, blockBottom = 0, lineOffset = 0; + bool hasInk = false; + const auto measureLines = [&](const std::vector& lines) { + for (const auto& line : lines) { + const auto bounds = renderer.getTextVerticalBounds(fontId, line.c_str()); + if (bounds.bottom > bounds.top) { + blockTop = hasInk ? std::min(blockTop, lineOffset + bounds.top) : lineOffset + bounds.top; + blockBottom = hasInk ? std::max(blockBottom, lineOffset + bounds.bottom) : lineOffset + bounds.bottom; + hasInk = true; + } + lineOffset += lineH + 2; + } + }; + measureLines(loremLines); + measureLines(localizedLines); + if (!hasInk) return; + + const int textBlockHeight = blockBottom - blockTop; + int y = textAreaTop + (textAreaHeight - textBlockHeight) / 2 - blockTop; + renderer.beginTextClip(0, textAreaTop, renderer.getScreenWidth(), textAreaHeight); + for (const auto& line : loremLines) { renderer.drawText(fontId, left, y, line.c_str()); y += lineH + 2; } + for (const auto& line : localizedLines) { + renderer.drawText(fontId, left, y, line.c_str()); + y += lineH + 2; + } + renderer.endTextClip(); } void FontSelectionActivity::listScreen(UiApp::ScreenType& screen, void* user) { @@ -385,9 +437,16 @@ void FontSelectionActivity::render(RenderLock&&) { GUI.drawHeader(renderer, header, tr(STR_FONT_FAMILY)); } + const int previewFontId = SETTINGS.getReaderFontId(); + const int textHeight = previewFontId == 0 ? 0 : renderer.getTextHeight(previewFontId); + const int labelHeight = renderer.getTextHeight(UI_10_FONT_ID); + const int threeLineHeight = 3 * (textHeight + 2) + labelHeight + 8 + metrics_.previewPadding * 2; + const int listRowHeight = uiListRowHeight(app_.theme(), UiListRowType::SingleLine); + const int listReserve = 2 * listRowHeight + app_.theme().listRowGap + metrics_.verticalSpacing; + previewHeight = std::min(std::max(0, usableHeight - listReserve), + std::max(usableHeight * metrics_.previewHeightPercent / 100, threeLineHeight)); const int previewTop = afterHeader; const int listTop = previewTop + previewHeight + metrics_.verticalSpacing; - const int previewFontId = SETTINGS.getReaderFontId(); const char* previewFontName = (previewFontIndex_ >= 0 && previewFontIndex_ < static_cast(fonts_.size())) ? fonts_[previewFontIndex_].name.c_str() : nullptr; diff --git a/src/activities/settings/KeyboardLayoutsActivity.cpp b/src/activities/settings/KeyboardLayoutsActivity.cpp index 35bce18951f..e5d5de9ca00 100644 --- a/src/activities/settings/KeyboardLayoutsActivity.cpp +++ b/src/activities/settings/KeyboardLayoutsActivity.cpp @@ -126,8 +126,9 @@ void KeyboardLayoutsActivity::buildListScreen(UiApp::ScreenType& screen) { screen.spacer(static_cast(metrics.verticalSpacing)); for (uint8_t i = 0; i < keyboard_layouts::COUNT; ++i) { - rowItems[i].value = isLocked(i) ? tr(STR_DEFAULT_VALUE) - : (workingMask & keyboard_layouts::bitAt(i) ? tr(STR_STATE_ON) : tr(STR_STATE_OFF)); + rowItems[i].toggle = !isLocked(i); + rowItems[i].toggleChecked = (workingMask & keyboard_layouts::bitAt(i)) != 0; + rowItems[i].value = isLocked(i) ? tr(STR_DEFAULT_VALUE) : nullptr; } fui::ListProps props; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index dd981c864c9..76d6f75401b 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -21,6 +21,7 @@ #include "ClockOffsetActivity.h" #include "ClockSyncActivity.h" #include "CrossPointSettings.h" +#include "DeviceCapabilities.h" #include "FontSelectionActivity.h" #if CROSSINK_SCALABLE_FONTS #include "TtfRenderOptionsActivity.h" @@ -642,7 +643,10 @@ void SettingsActivity::openScreenMarginPicker(const SettingInfo& setting) { StrId::STR_NONE_OPT, /*readerActivity=*/false, /*allowPowerAsConfirm=*/false, /*ignoreInitialConfirmRelease=*/false, /*showPercentValue=*/false, StrId::STR_NONE_OPT, /*overrideDisabledReaderTouchscreen=*/false, /*showTouchHeaderBackButton=*/true, - /*valueFormatter=*/nullptr, /*tapStep=*/5, /*useReaderSlider=*/true), + /*valueFormatter=*/nullptr, /*tapStep=*/5, /*useReaderSlider=*/true, + selectedSetting.valuePtr == &CrossPointSettings::screenMarginVertical + ? IntervalSelectionActivity::ReaderPreviewSetting::VerticalMargin + : IntervalSelectionActivity::ReaderPreviewSetting::HorizontalMargin), [this, selectedSetting](const ActivityResult& result) { if (!result.isCancelled) { SETTINGS.*(selectedSetting.valuePtr) = static_cast(std::clamp( @@ -662,7 +666,7 @@ void SettingsActivity::openWordSpacingPicker() { /*readerActivity=*/false, /*allowPowerAsConfirm=*/false, /*ignoreInitialConfirmRelease=*/false, /*showPercentValue=*/false, StrId::STR_NONE_OPT, /*overrideDisabledReaderTouchscreen=*/false, /*showTouchHeaderBackButton=*/true, /*valueFormatter=*/nullptr, /*tapStep=*/1, - /*useReaderSlider=*/true), + /*useReaderSlider=*/true, IntervalSelectionActivity::ReaderPreviewSetting::WordSpacing), [this](const ActivityResult& result) { if (!result.isCancelled) { SETTINGS.wordSpacing = @@ -979,34 +983,41 @@ void SettingsActivity::loop() { } requestUpdate(); }; - buttonNavigator.onNextRelease([this, &moveSelection] { - const int next = isFileBrowserView() ? (selectedSettingIndex >= settingsCount ? 1 : selectedSettingIndex + 1) - : ButtonNavigator::nextIndex(selectedSettingIndex, settingsCount + 1); - moveSelection(next, true); - }); - buttonNavigator.onPreviousRelease([this, &moveSelection] { - const int previous = isFileBrowserView() ? (selectedSettingIndex <= 1 ? settingsCount : selectedSettingIndex - 1) - : ButtonNavigator::previousIndex(selectedSettingIndex, settingsCount + 1); - moveSelection(previous, false); - }); + const auto up = mappedInput.menuButton(MappedInputManager::Button::Up); + const auto down = mappedInput.menuButton(MappedInputManager::Button::Down); + const auto navigateRows = [this, &moveSelection](const bool forward) { + const int index = isFileBrowserView() + ? (forward ? (selectedSettingIndex >= settingsCount ? 1 : selectedSettingIndex + 1) + : (selectedSettingIndex <= 1 ? settingsCount : selectedSettingIndex - 1)) + : (forward ? ButtonNavigator::nextIndex(selectedSettingIndex, settingsCount + 1) + : ButtonNavigator::previousIndex(selectedSettingIndex, settingsCount + 1)); + moveSelection(index, forward); + }; + const auto previousButtons = isFileBrowserView() ? ButtonNavigator::getPreviousButtons() : std::array{up, up}; + const auto nextButtons = isFileBrowserView() ? ButtonNavigator::getNextButtons() : std::array{down, down}; + buttonNavigator.onRelease(nextButtons, [&] { navigateRows(true); }); + buttonNavigator.onRelease(previousButtons, [&] { navigateRows(false); }); if (!isFileBrowserView()) { - buttonNavigator.onNextContinuous([this, &hasChangedCategory] { + buttonNavigator.onContinuous(nextButtons, [&] { navigateRows(true); }); + buttonNavigator.onContinuous(previousButtons, [&] { navigateRows(false); }); + const auto changeCategory = [this, &hasChangedCategory](const bool forward) { hasChangedCategory = true; showSettingSelection = true; - enterCategory(ButtonNavigator::nextIndex(selectedCategoryIndex, categoryCount)); + enterCategory(forward ? ButtonNavigator::nextIndex(selectedCategoryIndex, categoryCount) + : ButtonNavigator::previousIndex(selectedCategoryIndex, categoryCount)); requestUpdate(); - }); - - buttonNavigator.onPreviousContinuous([this, &hasChangedCategory] { - hasChangedCategory = true; - showSettingSelection = true; - enterCategory(ButtonNavigator::previousIndex(selectedCategoryIndex, categoryCount)); - requestUpdate(); - }); + }; + const auto left = mappedInput.menuButton(MappedInputManager::Button::Left); + const auto right = mappedInput.menuButton(MappedInputManager::Button::Right); + buttonNavigator.onRelease({right, right}, [&] { changeCategory(true); }); + buttonNavigator.onRelease({left, left}, [&] { changeCategory(false); }); + buttonNavigator.onContinuous({right, right}, [&] { changeCategory(true); }); + buttonNavigator.onContinuous({left, left}, [&] { changeCategory(false); }); } if (hasChangedCategory) { + topIndex = 0; selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1; setCurrentSettingsForCategory(); // Advance past any leading section headers @@ -1307,7 +1318,8 @@ void SettingsActivity::openLineHeightPicker() { StrId::STR_NONE_OPT, /*readerActivity=*/false, /*allowPowerAsConfirm=*/false, /*ignoreInitialConfirmRelease=*/false, /*showPercentValue=*/true, StrId::STR_NONE_OPT, /*overrideDisabledReaderTouchscreen=*/false, /*showTouchHeaderBackButton=*/true, - /*valueFormatter=*/nullptr, /*tapStep=*/5, /*useReaderSlider=*/true), + /*valueFormatter=*/nullptr, /*tapStep=*/5, /*useReaderSlider=*/true, + IntervalSelectionActivity::ReaderPreviewSetting::LineSpacing), [this](const ActivityResult& result) { if (!result.isCancelled) { SETTINGS.lineHeightPercent = CrossPointSettings::clampedLineHeightPercent( @@ -1398,6 +1410,8 @@ void SettingsActivity::buildSettingsScreen(UiApp::ScreenType& screen) { screen.setContentMargin(fui::Insets{static_cast(tabTop - safe.y), 0, static_cast(bottomMargin), 0}); if (isFileBrowserView()) { + const int16_t listInset = static_cast(metrics.listInset); + screen.insetContent(fui::Insets{0, listInset, 0, listInset}); screen.spacer(static_cast(metrics.verticalSpacing)); visibleRows = settingsCount; topIndex = 0; @@ -1409,6 +1423,7 @@ void SettingsActivity::buildSettingsScreen(UiApp::ScreenType& screen) { row.valueId = static_cast(i); row.labelText = screen.theme().bodyText; row.valueText = screen.theme().bodyText; + row.sidePadding = static_cast(metrics.listSidePadding); row.state = showSettingSelection && selectedSettingIndex == i + 1 ? fui::StateSelected : fui::StateNormal; if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { fui::ToggleRowProps toggle; @@ -1643,8 +1658,10 @@ void SettingsActivity::render(RenderLock&&) { ? tr(STR_SELECT) : tr(STR_TOGGLE)); - const auto labels = - mappedInput.mapLabels(mappedInput.withBackArrow(tr(STR_BACK)), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + const bool horizontalFront = !isFileBrowserView() && !deviceUsesHorizontalSideButtonsForMenus(gpio); + const auto labels = mappedInput.mapLabels(mappedInput.withBackArrow(tr(STR_BACK)), confirmLabel, + (horizontalFront ? tr(STR_DIR_LEFT) : tr(STR_DIR_UP)), + (horizontalFront ? tr(STR_DIR_RIGHT) : tr(STR_DIR_DOWN))); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); // Always use standard refresh for settings screen diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index febfa5c81ef..22115ee4fe6 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -326,6 +326,10 @@ class SettingsActivity final : public Activity { bool returnToParentOnClose = false, View view = View::Root); bool allowGlobalHomeSwipeGesture() const override { return false; } bool handleHomeGesture() override; +#ifdef SIMULATOR + int simulatorCategoryIndex() const { return selectedCategoryIndex; } + int simulatorSelectedIndex() const { return selectedSettingIndex; } +#endif void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/util/FrontlightPanelActivity.h b/src/activities/util/FrontlightPanelActivity.h index d8fa04d8529..294dadd7bc9 100644 --- a/src/activities/util/FrontlightPanelActivity.h +++ b/src/activities/util/FrontlightPanelActivity.h @@ -94,4 +94,7 @@ class FrontlightPanelActivity final : public Activity { // the overlay back to the current screen. bool handleHomeGesture() override; bool requiresFreshBackdrop() const override { return true; } +#ifdef SIMULATOR + void simulatorActivateQuickAction(int index) { activateQuickAction(index); } +#endif }; diff --git a/src/activities/util/IntervalSelectionActivity.cpp b/src/activities/util/IntervalSelectionActivity.cpp index bed9856fb95..08cf54448aa 100644 --- a/src/activities/util/IntervalSelectionActivity.cpp +++ b/src/activities/util/IntervalSelectionActivity.cpp @@ -1,14 +1,19 @@ #include "IntervalSelectionActivity.h" +#include #include #include #include +#include +#include #include #include #include +#include "CrossPointSettings.h" #include "DeviceCapabilities.h" +#include "SdCardFontSystem.h" #include "components/SliderValue.h" #include "components/TouchActionButtons.h" #include "components/TouchHeaderBackButton.h" @@ -76,7 +81,7 @@ IntervalSelectionActivity::IntervalSelectionActivity( const StrId valueFormatId, const bool readerActivity, const bool allowPowerAsConfirm, const bool ignoreInitialConfirmRelease, const bool showPercentValue, const StrId maxBoundaryLabelId, const bool overrideDisabledReaderTouchscreen, const bool showTouchHeaderBackButton, ValueFormatter valueFormatter, - const int tapStep, const bool useReaderSlider) + const int tapStep, const bool useReaderSlider, const ReaderPreviewSetting readerPreviewSetting) : Activity(activityName, renderer, mappedInput), titleId(titleId), valueFormatId(valueFormatId), @@ -96,10 +101,12 @@ IntervalSelectionActivity::IntervalSelectionActivity( tapStep(tapStep), #if CROSSINK_APP_CAP_TOUCH useReaderSlider(useReaderSlider), + readerPreviewSetting(readerPreviewSetting), uiTarget(makeUiTarget(renderer)), app(uiTarget, uiTarget.deviceContext()) #else - useReaderSlider(useReaderSlider) + useReaderSlider(useReaderSlider), + readerPreviewSetting(readerPreviewSetting) #endif { } @@ -185,8 +192,17 @@ void IntervalSelectionActivity::buildSliderScreen(UiApp::ScreenType& screen) { const int16_t controlTopInset = static_cast(lineHeight + READER_SLIDER_SCALE_GAP); const int16_t top = std::max( 0, static_cast((screen.body().height - READER_SLIDER_CONTROL_HEIGHT) / 2 - controlTopInset)); - screen.spacer(top); - const fui::Rect row = screen.takeTop(rowHeight); + const bool marginPreview = readerPreviewSetting == ReaderPreviewSetting::VerticalMargin || + readerPreviewSetting == ReaderPreviewSetting::HorizontalMargin; + screen.spacer(readerPreviewSetting == ReaderPreviewSetting::None ? top : 0); + const fui::Rect row = marginPreview ? screen.takeBottom(rowHeight) : screen.takeTop(rowHeight); + if (marginPreview) { + readerPreviewArea = + Rect{touchScreen.x, contentTop, touchScreen.width, std::max(0, row.y - metrics.verticalSpacing - contentTop)}; + } else if (readerPreviewSetting != ReaderPreviewSetting::None) { + const int previewTop = row.bottom() + metrics.verticalSpacing; + readerPreviewArea = Rect{touchScreen.x, previewTop, touchScreen.width, std::max(0, contentBottom - previewTop)}; + } const fui::Rect band{row.x, static_cast(row.y + controlTopInset), row.width, READER_SLIDER_CONTROL_HEIGHT}; const int16_t stepWidth = std::max(band.height, screen.theme().rowHeight); const int16_t sideGap = static_cast(stepWidth + screen.theme().spaceSm); @@ -262,6 +278,36 @@ void IntervalSelectionActivity::onStepEvent(const fui::ActionEvent& event, void* void IntervalSelectionActivity::onEnter() { Activity::onEnter(); + if (readerPreviewSetting != ReaderPreviewSetting::None) { + previewModel = makeUniqueNoThrow(); + if (!previewModel || !previewModel->captureParagraph(READER_PREVIEW_PARAGRAPH)) { + LOG_ERR("INTV", "Could not prepare reader settings preview"); + previewModel.reset(); + } + RenderLock lock; + sdFontSystem.ensureLoaded(renderer); + if (auto* fcm = renderer.getFontCacheManager(); previewModel && fcm) { + // Cold SD bitmap fonts need their glyphs prepared before measuring or drawing the sample. + const char* previewText = READER_PREVIEW_PARAGRAPH; + std::unique_ptr guideText; + if (SETTINGS.guideReadingEnabled) { + constexpr char guideDot[] = "\xc2\xb7"; + constexpr size_t guideTextSize = sizeof(READER_PREVIEW_PARAGRAPH) + sizeof(guideDot) - 1; + // Keep the combined paragraph off the activity's limited stack; free it after prewarming. + guideText = makeUniqueNoThrow(guideTextSize); + if (guideText) { + snprintf(guideText.get(), guideTextSize, "%s%s", READER_PREVIEW_PARAGRAPH, guideDot); + previewText = guideText.get(); + } else { + LOG_ERR("INTV", "Could not allocate guide reading preview text"); + } + } + const uint8_t styles = SETTINGS.focusReadingEnabled ? 0x03 : 0x01; + if (!fcm->prewarmCache(SETTINGS.getReaderFontId(), previewText, styles)) { + LOG_ERR("INTV", "Could not prepare reader settings preview glyphs"); + } + } + } ignoreConfirmRelease = ignoreConfirmRelease || mappedInput.isPressed(MappedInputManager::Button::Confirm); ignoreBackRelease = mappedInput.isPressed(MappedInputManager::Button::Back); ignorePowerRelease = mappedInput.isPressed(MappedInputManager::Button::Power); @@ -281,6 +327,7 @@ void IntervalSelectionActivity::onEnter() { } void IntervalSelectionActivity::onExit() { + previewModel.reset(); if (overrideDisabledReaderTouchscreen) { mappedInput.setReaderTouchscreenOverride(false); } @@ -307,6 +354,41 @@ void IntervalSelectionActivity::drawStepHintLine(const int y, const StrId labelI renderer.drawCenteredText(SMALL_FONT_ID, y, line, true); } +void IntervalSelectionActivity::renderReaderPreview(const Rect& area) { + if (readerPreviewSetting == ReaderPreviewSetting::None || area.width <= 0 || area.height <= 0) return; + const auto& metrics = UITheme::getInstance().getMetrics(); + const int labelHeight = renderer.getTextHeight(UI_10_FONT_ID); + const int textHeight = area.height - labelHeight - metrics.previewPadding * 2 - 4; + if (textHeight <= 0) return; + const int labelY = area.y + area.height - metrics.previewPadding - labelHeight; + renderer.drawLine(area.x, labelY - 4, area.x + area.width - 1, labelY - 4); + renderer.drawText(UI_10_FONT_ID, area.x + metrics.previewPadding, labelY, tr(STR_PREVIEW)); + if (!previewModel) { + renderer.drawCenteredText(UI_12_FONT_ID, area.y + area.height / 2, tr(STR_PREVIEW_UNAVAILABLE)); + return; + } + + const int fontId = SETTINGS.getReaderFontId(); + const uint8_t lineHeight = readerPreviewSetting == ReaderPreviewSetting::LineSpacing + ? CrossPointSettings::clampedLineHeightPercent(static_cast(value)) + : SETTINGS.lineHeightPercent; + const uint8_t wordSpacing = + readerPreviewSetting == ReaderPreviewSetting::WordSpacing ? static_cast(value) : SETTINGS.wordSpacing; + const int marginX = + readerPreviewSetting == ReaderPreviewSetting::HorizontalMargin ? value : SETTINGS.screenMarginHorizontal; + const int marginVertical = + readerPreviewSetting == ReaderPreviewSetting::VerticalMargin ? value : SETTINGS.screenMarginVertical; + const int marginY = marginVertical * textHeight / std::max(1, static_cast(renderer.getScreenHeight())); + const int left = area.x + metrics.previewPadding + marginX; + const int top = area.y + metrics.previewPadding + marginY; + const int width = std::max(1, area.width - metrics.previewPadding * 2 - marginX * 2); + const int height = std::max(0, textHeight - marginY * 2); + renderer.beginTextClip(left, top, width, height); + previewModel->renderText(renderer, fontId, left, top, width, lineHeight, wordSpacing, SETTINGS.paragraphAlignment, + SETTINGS.focusReadingEnabled, SETTINGS.guideReadingEnabled, true, top + height); + renderer.endTextClip(); +} + void IntervalSelectionActivity::loop() { if (InputReleaseGuard::consumeInitialRelease(mappedInput, MappedInputManager::Button::Back, ignoreBackRelease) || InputReleaseGuard::consumeInitialRelease(mappedInput, MappedInputManager::Button::Power, ignorePowerRelease)) { @@ -496,6 +578,7 @@ void IntervalSelectionActivity::render(RenderLock&&) { uiReady = false; app.render(); uiReady = true; + renderReaderPreview(readerPreviewArea); const Rect header = TouchHeaderBackButton::headerRect(renderer, mappedInput); TouchHeaderBackButton::draw(renderer, header, I18N.get(titleId), readerActivity); @@ -540,6 +623,12 @@ void IntervalSelectionActivity::render(RenderLock&&) { const int knobX = std::max(barX + 2, barX + 2 + fillWidth - 2); renderer.fillRect(knobX, barY - 4, 4, barHeight + 8, true); + if (readerPreviewSetting != ReaderPreviewSetting::None && !mappedInput.hasTouchHardware()) { + const int previewTop = barY + 72; + const int previewBottom = safe.y + safe.height - metrics.buttonHintsHeight - metrics.verticalSpacing; + renderReaderPreview(Rect{safe.x, previewTop, safe.width, std::max(0, previewBottom - previewTop)}); + } + if (mappedInput.hasTouch()) { if (usesTextTouchStepControls()) { char labels[4][12]; diff --git a/src/activities/util/IntervalSelectionActivity.h b/src/activities/util/IntervalSelectionActivity.h index 7a7dd24c62e..868b5f269ef 100644 --- a/src/activities/util/IntervalSelectionActivity.h +++ b/src/activities/util/IntervalSelectionActivity.h @@ -4,6 +4,7 @@ #include #include +#include #if CROSSINK_APP_CAP_TOUCH #include @@ -14,6 +15,8 @@ #include "MappedInputManager.h" #include "activities/Activity.h" +#include "activities/reader/TouchReaderPreviewModel.h" +#include "components/themes/BaseTheme.h" #include "util/ButtonNavigator.h" class GfxRenderer; @@ -21,6 +24,7 @@ class GfxRenderer; class IntervalSelectionActivity final : public Activity { public: using ValueFormatter = void (*)(int value, char* buf, size_t len); + enum class ReaderPreviewSetting : uint8_t { None, LineSpacing, WordSpacing, VerticalMargin, HorizontalMargin }; explicit IntervalSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const char* activityName, StrId titleId, int initialValue, int minValue, int maxValue, int smallStep, @@ -30,7 +34,8 @@ class IntervalSelectionActivity final : public Activity { StrId maxBoundaryLabelId = StrId::STR_NONE_OPT, bool overrideDisabledReaderTouchscreen = false, bool showTouchHeaderBackButton = false, ValueFormatter valueFormatter = nullptr, - int tapStep = 0, bool useReaderSlider = false); + int tapStep = 0, bool useReaderSlider = false, + ReaderPreviewSetting readerPreviewSetting = ReaderPreviewSetting::None); void onEnter() override; void onExit() override; @@ -59,6 +64,9 @@ class IntervalSelectionActivity final : public Activity { ValueFormatter valueFormatter; int tapStep; bool useReaderSlider; + ReaderPreviewSetting readerPreviewSetting; + std::unique_ptr previewModel; + Rect readerPreviewArea{}; bool draggingBar = false; ButtonNavigator buttonNavigator; @@ -87,4 +95,5 @@ class IntervalSelectionActivity final : public Activity { int tappedValue(int candidate) const; bool usesTextTouchStepControls() const; void drawStepHintLine(int y, StrId labelId, int step); + void renderReaderPreview(const Rect& area); }; diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index e87992375bb..c9135edfc5b 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -136,6 +136,11 @@ const fui::KeyboardLayout URL_SNIPPET_LAYOUT{URL_SNIP_ROWS, 4}; void KeyboardEntryActivity::onEnter() { Activity::onEnter(); + inputLineHeight = renderer.getLineHeight(UI_12_FONT_ID); +#if CROSSINK_APP_CAP_TOUCH + pendingFeedback = 0; + feedbackSequence = 0; +#endif cursorPos = text.length(); layoutId = inputType == InputType::Url ? fui::KeyboardLayoutId::QwertyEn : keyboard_layouts::startingLayout(); const uint16_t enabledLayouts = keyboard_layouts::enabled(); @@ -148,6 +153,7 @@ void KeyboardEntryActivity::onEnter() { passwordVisible = false; selRow = 0; selCol = 0; + buttonSelectionVisible = !mappedInput.hasTouchHardware(); delPressCount = 0; hintVisible = false; hintShowTime = 0; @@ -164,6 +170,17 @@ void KeyboardEntryActivity::onEnter() { void KeyboardEntryActivity::onExit() { Activity::onExit(); } +#if CROSSINK_APP_CAP_TOUCH +void KeyboardEntryActivity::showTouchFeedback(const int16_t value) { + // Keep zero reserved for no feedback, including after sequence wraparound. + // cppcheck-suppress knownConditionTrueFalse ; uint16_t wraps to zero after 65535 keys + if (++feedbackSequence == 0) ++feedbackSequence; + pendingFeedback.store((static_cast(feedbackSequence) << 16) | static_cast(value)); + requestUpdate(); +} + +#endif + const fui::KeyboardLayout& KeyboardEntryActivity::currentLayout() const { if (symbols) return fui::builtinKeyboardLayout(layoutId, shifted, true); if (inputType == InputType::Url) { @@ -207,6 +224,7 @@ void KeyboardEntryActivity::clampSelection() { void KeyboardEntryActivity::moveSelectionRow(const int delta) { const fui::KeyboardLayout& layout = currentLayout(); if (layout.rowCount == 0) return; + buttonSelectionVisible = true; const int oldCols = selRow < layout.rowCount ? layout.rows[selRow].count : 1; selRow = (selRow + delta + layout.rowCount) % layout.rowCount; const int newCols = layout.rows[selRow].count; @@ -223,6 +241,7 @@ void KeyboardEntryActivity::moveSelectionCol(const int delta) { if (selRow < 0 || selRow >= layout.rowCount) return; const int cols = layout.rows[selRow].count; if (cols <= 0) return; + buttonSelectionVisible = true; selCol = (selCol + delta + cols) % cols; } @@ -437,7 +456,7 @@ KeyboardEntryActivity::InputFieldTouchTarget KeyboardEntryActivity::inputFieldTo const int pageWidth = renderer.getScreenWidth(); const auto& metrics = UITheme::getInstance().getMetrics(); - const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int lineHeight = inputLineHeight; const int inputStartY = metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + metrics.verticalSpacing + metrics.verticalSpacing * 4 + metrics.keyboardVerticalOffset; @@ -541,7 +560,7 @@ fui::Rect KeyboardEntryActivity::keyboardRect() const { if (hasTouch) { const int inputStartY = metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + metrics.verticalSpacing * 5 + metrics.keyboardVerticalOffset; - const int inputBottom = inputStartY + renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing + 8; + const int inputBottom = inputStartY + inputLineHeight + metrics.verticalSpacing + 8; y = std::max(y, inputBottom); } return fui::Rect{0, static_cast(y), static_cast(pageWidth), @@ -562,6 +581,7 @@ void KeyboardEntryActivity::loop() { size_t touchedCursorPos = 0; const InputFieldTouchTarget inputTarget = inputFieldTouchTargetFromPoint(tx, ty, touchedCursorPos); if (inputTarget == InputFieldTouchTarget::PasswordToggle) { + buttonSelectionVisible = false; passwordVisible = !passwordVisible; togglePos = false; hintVisible = false; @@ -569,6 +589,7 @@ void KeyboardEntryActivity::loop() { return; } if (inputTarget == InputFieldTouchTarget::Cursor) { + buttonSelectionVisible = false; cursorPos = std::min(touchedCursorPos, text.length()); cursorMode = false; togglePos = false; @@ -597,16 +618,20 @@ void KeyboardEntryActivity::loop() { const fui::TouchHoldRouter::Result result = touchRouter.update(interactions, tapCandidate, static_cast(tx), static_cast(ty), tapped, static_cast(tapX), static_cast(tapY), inContact, millis()); + if (result.activeChanged && interactions.activeIndex() >= 0) { + buttonSelectionVisible = false; + showTouchFeedback(interactions.publishedData()[interactions.activeIndex()].value); + } if (result.event) { + buttonSelectionVisible = false; + showTouchFeedback(result.event.value); syncSelectionToValue(result.event.value); if (activateValue(result.event.value, result.event.longPress)) { requestUpdate(); } return; } - if (result.activeChanged) { - requestUpdate(); - } + // Feedback requests coalesce while the panel is busy; input keeps polling. if (tapCandidate || tapped) { return; } @@ -642,6 +667,7 @@ void KeyboardEntryActivity::loop() { togglePos = false; passwordVisible = false; cursorMode = false; + buttonSelectionVisible = true; hintVisible = false; downLongHandled = true; requestUpdate(); @@ -717,8 +743,15 @@ void KeyboardEntryActivity::loop() { } if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - confirmHeld = true; - confirmLongHandled = false; + if (mappedInput.hasTouchHardware() && !buttonSelectionVisible.load()) { + // Touch typing hides the button focus. Reveal it first so Confirm cannot + // activate a key whose selection is not visible. + buttonSelectionVisible = true; + requestUpdate(); + } else { + confirmHeld = true; + confirmLongHandled = false; + } } const fui::KeyboardKey* selKey = selectedKey(); @@ -764,6 +797,9 @@ void KeyboardEntryActivity::loop() { } void KeyboardEntryActivity::render(RenderLock&&) { +#if CROSSINK_APP_CAP_TOUCH + const uint32_t feedback = pendingFeedback.load(); +#endif renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); @@ -776,7 +812,7 @@ void KeyboardEntryActivity::render(RenderLock&&) { GUI.drawHeader(renderer, header, title.c_str()); } - const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int lineHeight = inputLineHeight; const int inputStartY = metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + metrics.verticalSpacing + metrics.verticalSpacing * 4 + metrics.keyboardVerticalOffset; int inputHeight = 0; @@ -1020,8 +1056,17 @@ void KeyboardEntryActivity::render(RenderLock&&) { target.setFont(fui::GfxRendererTarget::FONT_BODY, UI_12_FONT_ID); const fui::DeviceContext device = target.deviceContext(); const fui::InputSnapshot noInput{}; - interactions.beginPublishCycle(); - fui::Frame<56> frame(target, device, noInput, interactions); +#if CROSSINK_APP_CAP_TOUCH + auto& frameInteractions = paintInteractions; + frameInteractions.clearFlash(); + if (feedback && !cursorMode && !buttonSelectionVisible.load()) { + frameInteractions.setFlash(ACTION_KEY, static_cast(feedback & 0xFFFF)); + } +#else + auto& frameInteractions = interactions; + frameInteractions.beginPublishCycle(); +#endif + fui::Frame<56> frame(target, device, noInput, frameInteractions); fui::KeyboardProps props; const fui::KeyboardLayout& layout = currentLayout(); @@ -1077,7 +1122,8 @@ void KeyboardEntryActivity::render(RenderLock&&) { props.modeLabel = (symbols || (inputType == InputType::Url && urlPanel)) ? tr(STR_KEY_MODE_ABC) : tr(STR_KEY_MODE_SYMBOLS); props.inputMask = static_cast(fui::InputTouch | fui::InputLongPress); - props.selectedIndex = cursorMode ? -1 : static_cast(selectedLogicalIndex()); + props.selectedIndex = + (cursorMode || !buttonSelectionVisible.load()) ? -1 : static_cast(selectedLogicalIndex()); props.labelText.font = layoutId == fui::KeyboardLayoutId::ArabicAr && !symbols ? fui::GfxRendererTarget::FONT_SMALL : fui::GfxRendererTarget::FONT_BODY; props.altText.font = fui::GfxRendererTarget::FONT_SMALL; @@ -1088,12 +1134,22 @@ void KeyboardEntryActivity::render(RenderLock&&) { props.altHintRightPadding = 5; } frame.target().fill(kbRect, props.background); + // The SDK fills the keys rect with this paint too. The outer fill already + // covers it and the surrounding panel, so avoid drawing that area twice. + props.background = fui::Paint::none(); // Fingers land low on the bottom row (occlusion) and there is no key below // to catch the miss — extend its hit band down to the button hints bar. const int bottomEdge = mappedInput.hasTouchHardware() ? renderer.getScreenHeight() : renderer.getScreenHeight() - metrics.buttonHintsHeight; props.bottomHitOverflow = static_cast(std::max(0, bottomEdge - keysRect.bottom())); fui::keyboard(frame, keysRect, props); +#if CROSSINK_APP_CAP_TOUCH + interactions.beginPublishCycle(); + interactions.clear(); + for (size_t i = 0; i < frameInteractions.count(); ++i) { + interactions.addInteraction(frameInteractions.data()[i]); + } +#endif interactions.publish(); interactionsReady.store(true, std::memory_order_release); @@ -1104,6 +1160,14 @@ void KeyboardEntryActivity::render(RenderLock&&) { GUI.drawSideButtonHints(renderer, ">", "<"); renderer.displayBuffer(); +#if CROSSINK_APP_CAP_TOUCH + // Only acknowledge the frame actually displayed. A newer press (including + // the same key again) must survive an older refresh completing. + uint32_t expected = feedback; + if (feedback && pendingFeedback.compare_exchange_strong(expected, 0)) { + requestUpdate(); // Remove the flash even if typing has stopped. + } +#endif } void KeyboardEntryActivity::onComplete(std::string text) { diff --git a/src/activities/util/KeyboardEntryActivity.h b/src/activities/util/KeyboardEntryActivity.h index b0b77fc8065..b5311c29034 100644 --- a/src/activities/util/KeyboardEntryActivity.h +++ b/src/activities/util/KeyboardEntryActivity.h @@ -34,6 +34,7 @@ class KeyboardEntryActivity : public Activity { void loop() override; void render(RenderLock&&) override; bool preventAutoSleep() override { return true; } + uint8_t inputPollDelayMs() const override { return mappedInput.hasTouchHardware() ? 2 : 10; } private: std::string title; @@ -42,6 +43,9 @@ class KeyboardEntryActivity : public Activity { InputType inputType; size_t minLength; bool passwordVisible = false; + // Fixed UI font metric, captured before input starts. Querying the renderer + // during hit testing can wait for an entire e-ink refresh on TTF builds. + int inputLineHeight = 0; ButtonNavigator buttonNavigator; @@ -58,11 +62,21 @@ class KeyboardEntryActivity : public Activity { // loop() routes touch snapshots against them. Cyrillic's wider rows register // 48 keys, so 56 retains headroom for the double-buffered interaction table. freeink::ui::InteractionBuffer<56> interactions; +#if CROSSINK_APP_CAP_TOUCH + // Render-only state prevents live contact changes from altering a frame's + // feedback. Fixed storage is reused for the lifetime of this activity. + freeink::ui::InteractionBuffer<56> paintInteractions; + std::atomic pendingFeedback{0}; + uint16_t feedbackSequence = 0; // Main task only; distinguishes repeated keys. + void showTouchFeedback(int16_t value); +#endif // GPIO selection over the current layout grid (row/col in layout terms; // the bottom action row is just the last row). int selRow = 0; int selCol = 0; + // Touch entry has no persistent key highlight; button navigation reveals it. + std::atomic buttonSelectionVisible{false}; bool confirmHeld = false; bool confirmLongHandled = false; diff --git a/src/components/HomeCoverCache.cpp b/src/components/HomeCoverCache.cpp index 0852f435b34..bb8e1674e5e 100644 --- a/src/components/HomeCoverCache.cpp +++ b/src/components/HomeCoverCache.cpp @@ -47,8 +47,7 @@ bool HomeCoverCache::paint(fui::Rect rect, size_t index, const std::string& path 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)) { + renderer.copyBufferToRegion(rect.x, rect.y, rect.width, rect.height, &coverCache[cached.offset], cached.bytes)) { return true; } cached.valid = false; @@ -80,8 +79,8 @@ bool HomeCoverCache::paint(fui::Rect rect, size_t index, const std::string& path } 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); + cached.valid = renderer.copyRegionToBuffer(rect.x, rect.y, rect.width, rect.height, &coverCache[cached.offset], + cached.bytes); } } return true; // The cover slot is painted, including fallback art. diff --git a/src/components/themes/lyra/LyraCarouselTheme.cpp b/src/components/themes/lyra/LyraCarouselTheme.cpp index e21f6715d3e..1eabc9ab54d 100644 --- a/src/components/themes/lyra/LyraCarouselTheme.cpp +++ b/src/components/themes/lyra/LyraCarouselTheme.cpp @@ -517,45 +517,14 @@ void LyraCarouselTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, dotX += kDotSize + kDotGap; } - // Minimal-style reading progress footer below the cover. - constexpr int footerLabelFontId = UI_10_FONT_ID; - const int footerLabelLineHeight = renderer.getLineHeight(footerLabelFontId); - const bool hasStats = (stats != nullptr && stats->sessionCount > 0); - const bool hasProgress = progressPercent >= 0.0f; - int infoY = dotsY + kDotSize + kFooterTopGap; - const int footerMaxWidth = std::max(0, screenW - 2 * LyraCarouselMetrics::values.contentSidePadding); - const int footerWidth = std::min(footerMaxWidth, centerCoverRect.width); - const int footerX = centerCoverRect.x + (centerCoverRect.width - footerWidth) / 2; - - if (hasStats) { - char buf[48]; - formatCompactReadingTime(stats->totalReadingSeconds, buf, sizeof(buf)); - const auto timeLabel = renderer.truncatedText(footerLabelFontId, buf, footerWidth, EpdFontFamily::REGULAR); - renderer.drawText(footerLabelFontId, footerX, infoY, timeLabel.c_str(), true, EpdFontFamily::REGULAR); - } - - if (hasProgress) { - const int progressBarY = infoY + (hasStats ? footerLabelLineHeight + kFooterLabelToBarGap : 0); - const float clampedProgress = std::clamp(progressPercent, 0.0f, 100.0f); - const int filledWidth = std::clamp(static_cast((clampedProgress / 100.0f) * footerWidth), 0, footerWidth); - char progressLabel[16]; - snprintf(progressLabel, sizeof(progressLabel), "%.0f%%", clampedProgress); - renderer.fillRectDither(footerX, progressBarY, footerWidth, kFooterProgressBarHeight, Color::LightGray); - if (filledWidth > 0) { - renderer.fillRect(footerX, progressBarY, filledWidth, kFooterProgressBarHeight, true); - } - const int progressLabelW = renderer.getTextWidth(footerLabelFontId, progressLabel, EpdFontFamily::REGULAR); - const int progressLabelY = progressBarY + kFooterProgressBarHeight + kFooterPercentTopGap; - renderer.drawText(footerLabelFontId, footerX + footerWidth - progressLabelW, progressLabelY, progressLabel, true, - EpdFontFamily::REGULAR); - } - coverBufferStored = storeCoverBuffer(); coverRendered = coverBufferStored; } else if (lastCenterCoverRect.width <= 0 || lastCenterCoverRect.height <= 0) { lastCenterCoverRect = shrinkCenterCoverRect(centerCoverSlotRect); } + drawReadingProgress(renderer, rect, recentBooks, stats, progressPercent); + // Always outline the centre cover at its own edge (white ring sits outside the black line); // thicker when the carousel row is active registerCarouselCoverTouchTargets(renderer, rect, recentBooks, centerIdx); @@ -564,6 +533,48 @@ void LyraCarouselTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, lastCenterCoverRect.height, outlineW, kCornerRadius, true); } +void LyraCarouselTheme::drawReadingProgress(const GfxRenderer& renderer, Rect rect, + const std::vector& recentBooks, const BookReadingStats* stats, + float progressPercent) const { + if (recentBooks.empty()) return; + const int screenW = renderer.getScreenWidth(); + const Rect centerCoverSlotRect = computeCenterCoverSlotRect(renderer, rect, recentBooks); + const Rect centerCoverRect = shrinkCenterCoverRect(centerCoverSlotRect); + const int dotsY = centerCoverSlotRect.y + centerCoverSlotRect.height + 8; + // Minimal-style reading progress footer below the cover. + constexpr int footerLabelFontId = UI_10_FONT_ID; + const int footerLabelLineHeight = renderer.getLineHeight(footerLabelFontId); + const bool hasStats = (stats != nullptr && stats->sessionCount > 0); + const bool hasProgress = progressPercent >= 0.0f; + int infoY = dotsY + kDotSize + kFooterTopGap; + const int footerMaxWidth = std::max(0, screenW - 2 * LyraCarouselMetrics::values.contentSidePadding); + const int footerWidth = std::min(footerMaxWidth, centerCoverRect.width); + const int footerX = centerCoverRect.x + (centerCoverRect.width - footerWidth) / 2; + + if (hasStats) { + char buf[48]; + formatCompactReadingTime(stats->totalReadingSeconds, buf, sizeof(buf)); + const auto timeLabel = renderer.truncatedText(footerLabelFontId, buf, footerWidth, EpdFontFamily::REGULAR); + renderer.drawText(footerLabelFontId, footerX, infoY, timeLabel.c_str(), true, EpdFontFamily::REGULAR); + } + + if (hasProgress) { + const int progressBarY = infoY + (hasStats ? footerLabelLineHeight + kFooterLabelToBarGap : 0); + const float clampedProgress = std::clamp(progressPercent, 0.0f, 100.0f); + const int filledWidth = std::clamp(static_cast((clampedProgress / 100.0f) * footerWidth), 0, footerWidth); + char progressLabel[16]; + snprintf(progressLabel, sizeof(progressLabel), "%.0f%%", clampedProgress); + renderer.fillRectDither(footerX, progressBarY, footerWidth, kFooterProgressBarHeight, Color::LightGray); + if (filledWidth > 0) { + renderer.fillRect(footerX, progressBarY, filledWidth, kFooterProgressBarHeight, true); + } + const int progressLabelW = renderer.getTextWidth(footerLabelFontId, progressLabel, EpdFontFamily::REGULAR); + const int progressLabelY = progressBarY + kFooterProgressBarHeight + kFooterPercentTopGap; + renderer.drawText(footerLabelFontId, footerX + footerWidth - progressLabelW, progressLabelY, progressLabel, true, + EpdFontFamily::REGULAR); + } +} + // --------------------------------------------------------------------------- // Horizontal icon-only menu row — anchored to bottom of screen // --------------------------------------------------------------------------- @@ -632,46 +643,6 @@ Rect LyraCarouselTheme::buttonMenuTouchRect(const GfxRenderer& renderer, const i return Rect{0, metrics.labelY, renderer.getScreenWidth(), metrics.rowY + metrics.tileH - metrics.labelY}; } -void LyraCarouselTheme::drawButtonMenuSelectionOverlay(const GfxRenderer& renderer, int buttonCount, int selectedIndex, - const std::function& buttonLabel, - const std::function& rowIcon) const { - if (buttonCount <= 0 || selectedIndex < 0 || selectedIndex >= buttonCount) return; - - const MenuLayoutMetrics metrics = computeMenuLayout(renderer, buttonCount); - registerButtonMenuTouchTargets(renderer, buttonCount); - - const int tileX = selectedIndex * metrics.tileW; - const int iconX = tileX + (metrics.tileW - kMenuIconSize) / 2; - const int iconY = metrics.rowY + kMenuIconPad; - const int highlightSize = kMenuIconSize + 2 * kHighlightPad; - const int highlightY = metrics.rowY + (metrics.tileH - highlightSize) / 2; - - renderer.fillRoundedRect(iconX - kHighlightPad, highlightY, highlightSize, highlightSize, kCornerRadius, - Color::Black); - - if (rowIcon != nullptr) { - const UIIcon icon = rowIcon(selectedIndex); - if (icon == UIIcon::BookmarkIcon) { - drawMenuBookmarkIcon(renderer, iconX, iconY, true); - } else { - const freeink::Icon* bmp = iconForName(icon, kMenuIconSize); - if (bmp != nullptr) { - drawLucideIcon(renderer, *bmp, iconX, iconY, false); - } - } - } - - renderer.fillRect(0, metrics.labelY, renderer.getScreenWidth(), metrics.labelLineHeight, false); - if (buttonLabel != nullptr) { - char centeredLabel[kMenuLabelBufferSize]; - fitMenuLabel(renderer, buttonLabel(selectedIndex), renderer.getScreenWidth() - 40, centeredLabel, - sizeof(centeredLabel)); - const int labelWidth = renderer.getTextWidth(kMenuLabelFontId, centeredLabel, EpdFontFamily::REGULAR); - renderer.drawText(kMenuLabelFontId, (renderer.getScreenWidth() - labelWidth) / 2, metrics.labelY + 2, centeredLabel, - true, EpdFontFamily::REGULAR); - } -} - // --------------------------------------------------------------------------- // List — solid black highlight, inverted text and icons on selected row // --------------------------------------------------------------------------- diff --git a/src/components/themes/lyra/LyraCarouselTheme.h b/src/components/themes/lyra/LyraCarouselTheme.h index 9ee41fff5ba..8247885a6e0 100644 --- a/src/components/themes/lyra/LyraCarouselTheme.h +++ b/src/components/themes/lyra/LyraCarouselTheme.h @@ -50,6 +50,9 @@ class LyraCarouselTheme : public LyraTheme { const std::function& storeCoverBuffer, const BookReadingStats* stats = nullptr, float progressPercent = -1.0f, const GlobalReadingStats* globalStats = nullptr, const char* currentChapterTitle = nullptr) const override; + // Live footer, shared by direct drawing and cached-artwork restoration. + void drawReadingProgress(const GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + const BookReadingStats* stats, float progressPercent) const; void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, const std::function& buttonLabel, const std::function& rowIcon) const override; @@ -57,10 +60,6 @@ class LyraCarouselTheme : public LyraTheme { // Home uses this same geometry to consume swipes that start over the menu. static Rect buttonMenuTouchRect(const GfxRenderer& renderer, int buttonCount); void registerButtonMenuTouchTargets(const GfxRenderer& renderer, int buttonCount) const; - // LyraTheme has no virtual overlay hook; this is a carousel-only helper. - void drawButtonMenuSelectionOverlay(const GfxRenderer& renderer, int buttonCount, int selectedIndex, - const std::function& buttonLabel, - const std::function& rowIcon) const; void drawCarouselBorder(GfxRenderer& renderer, Rect coverRect, const std::vector& recentBooks, int centerIdx, bool inCarouselRow) const override; void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, diff --git a/src/main.cpp b/src/main.cpp index 603bba13b2a..af2dd9dc99e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,8 @@ #include #include #include +#include +#include #include #include #include @@ -260,6 +262,10 @@ void logMemoryStats(const char* phase, const bool onlyIfChanged = false) { #endif } +// Retain only a successfully reconciled Library across real deep-sleep wakes. +// Consume the pair on every boot, including resets that preserve RTC memory. +RTC_NOINIT_ATTR library::ScanSleepToken libraryScanSleepToken; + // Definitions for SilentRestart.h. RTC_NOINIT survives ESP.restart() but not power loss. RTC_NOINIT_ATTR uint32_t silentRebootMagic; RTC_NOINIT_ATTR uint32_t silentRebootTarget; @@ -1090,6 +1096,7 @@ void enterDeepSleep(bool fromTimeout) { BatteryDiagnosticLog::record(BatteryDiagnosticLog::Event::Sleep, BoardConfig::ACTIVE.name); // All sleep-time file writes are complete. Stop SDMMC before the power path // cuts peripheral rails and isolates the bus pads; SPI boards are a no-op. + libraryScanSleepToken.save(!library::libraryIndexNeedsRefresh()); Storage.shutdown(); putTiltSensorToSleepForDeepSleep(); @@ -1192,6 +1199,7 @@ void setup() { const esp_reset_reason_t rawResetReason = esp_reset_reason(); const esp_sleep_wakeup_cause_t rawWakeupCause = esp_sleep_get_wakeup_cause(); + if (libraryScanSleepToken.consume(rawResetReason == ESP_RST_DEEPSLEEP)) library::restoreLibraryIndexAfterSleep(); #ifdef ENABLE_SERIAL_LOG #ifdef CROSSPOINT_WAIT_FOR_USB_SERIAL @@ -1391,6 +1399,7 @@ void setup() { } } Frontlight.releaseAfterWake(); + LOG_DBG("LIGHT", "Frontlight boot state: %s (silent=%d)", restoreLightOn ? "on" : "off", isSilentReboot ? 1 : 0); Frontlight.begin(SETTINGS.frontlightBrightness, SETTINGS.frontlightWarmth, restoreLightOn); if (recoveryFirmwareMode) { @@ -1982,13 +1991,15 @@ void loop() { // Add delay at the end of the loop to prevent tight spinning // When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response // Otherwise, use longer delay to save power + const uint8_t inputPollDelayMs = activityManager.inputPollDelayMs(); bool skipLoopDelay = false; { // Reader scheduling inspects state also owned by the render task. Never wait // here: the input loop must stay available while a page is being rendered. RenderLock lock(RenderLock::Mode::Try); if (!lock.ownsLock()) { - delay(10); + // Continue polling at the activity's rate while its screen is drawn. + delay(inputPollDelayMs); return; } skipLoopDelay = activityManager.skipLoopDelay(); @@ -2008,7 +2019,7 @@ void loop() { InputWake::wait(idleWaitMs(millis() - lastActivityTime)); } else { // Short delay to prevent tight loop while still being responsive - InputWake::wait(10); + InputWake::wait(inputPollDelayMs); } } } diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index b9683c194a0..167e942d227 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -47,6 +47,7 @@ #include "html/StyleCss.generated.h" #include "html/js/jszip_minJs.generated.h" #include "util/BookCacheUtils.h" +#include "util/BookMoveUtils.h" #include "util/BootReason.h" #include "util/BuildInfo.h" #include "util/FontFamilyLabel.h" @@ -1471,21 +1472,36 @@ void CrossPointWebServer::handleRename() const { return; } - clearBookCache(itemPath.c_str()); - const bool success = file.rename(newPath.c_str()); + // Release the validation handle before migrating metadata and renaming the + // book; real SD cards cannot open the same path through multiple readers. file.close(); + const auto migration = BookMoveUtils::renameFilePreservingBookState(itemPath.c_str(), newPath.c_str()); + if (migration == BookMoveUtils::RenameMigrationResult::InvalidBookType) { + server->send(400, "text/plain", "Renaming a book cannot change its file type"); + return; + } + if (migration == BookMoveUtils::RenameMigrationResult::DestinationStateExists) { + server->send(409, "text/plain", "Target filename has saved reading data. Choose another filename."); + return; + } + if (migration == BookMoveUtils::RenameMigrationResult::RolledBack) { + LOG_ERR("WEB", "Failed to rename file while preserving reader state: %s -> %s", itemPath.c_str(), newPath.c_str()); + server->send(500, "text/plain", "Could not rename file while preserving saved reading data"); + return; + } - if (success) { - LOG_DBG("WEB", "Renamed file: %s -> %s", itemPath.c_str(), newPath.c_str()); - ImageFolderIndex::invalidateForPath(itemPath.c_str()); - sdFontSystem.markRegistryDirtyForPath(itemPath.c_str()); - ImageFolderIndex::invalidateForPath(newPath.c_str()); - sdFontSystem.markRegistryDirtyForPath(newPath.c_str()); - server->send(200, "text/plain", "Renamed successfully"); - } else { - LOG_ERR("WEB", "Failed to rename file: %s -> %s", itemPath.c_str(), newPath.c_str()); - server->send(500, "text/plain", "Failed to rename file"); + LOG_DBG("WEB", "Renamed file: %s -> %s", itemPath.c_str(), newPath.c_str()); + ImageFolderIndex::invalidateForPath(itemPath.c_str()); + sdFontSystem.markRegistryDirtyForPath(itemPath.c_str()); + ImageFolderIndex::invalidateForPath(newPath.c_str()); + sdFontSystem.markRegistryDirtyForPath(newPath.c_str()); + if (migration == BookMoveUtils::RenameMigrationResult::KeepRenamed) { + LOG_ERR("WEB", "Rename kept new path after incomplete state rollback: %s", newPath.c_str()); + server->send(500, "text/plain", + "File was renamed, but some saved references could not be updated. Refresh the file list."); + return; } + server->send(200, "text/plain", "Renamed successfully"); } void CrossPointWebServer::handleMove() const { diff --git a/src/simulator/SimulatorSmokeTest.cpp b/src/simulator/SimulatorSmokeTest.cpp index d0c859103da..a1ce8663448 100644 --- a/src/simulator/SimulatorSmokeTest.cpp +++ b/src/simulator/SimulatorSmokeTest.cpp @@ -11,10 +11,10 @@ #include #include #include +#include #if CROSSINK_SCALABLE_FONTS #include -#include #include #include "FontInstaller.h" @@ -30,13 +30,17 @@ #include "SdCardFontSystem.h" #include "SettingsList.h" #include "activities/ActivityManager.h" +#include "activities/home/HomeActivity.h" #include "activities/home/RecentBookProgress.h" +#include "activities/reader/BookReadingStats.h" #include "activities/reader/EpubReaderDrawerActivity.h" #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 "activities/settings/SettingsActivity.h" +#include "activities/util/FrontlightPanelActivity.h" #include "components/TouchHeaderBackButton.h" #include "components/UITheme.h" #include "simulator/SimulatorHomeKeyInput.h" @@ -61,6 +65,11 @@ enum class SmokeStep : uint8_t { Sleep, Reader, ReaderInput, + CarouselHome, + ThemeHome, + ThemeSettings, + ThemeReturned, + ThemeFresh, Done, }; @@ -92,11 +101,14 @@ class SimulatorSmokeTest { AssertTouchscreenEnabled, AssertTtfProfileNative, OpenSmokeBook, + OpenFrontlightSettings, DisableReaderTouch, EnableReaderTouch, TouchDown, TouchMove, TouchRelease, + AssertReaderMenu, + AssertSettingsNavigation, AssertActivity, Render }; @@ -118,6 +130,14 @@ class SimulatorSmokeTest { unsigned libraryRefreshPass = 0; uint16_t libraryBaselineBooks = 0; SmokeStep inputCompletionStep = SmokeStep::Done; + unsigned carouselCachePass = 0; + std::filesystem::file_time_type carouselCacheWrittenAt; + std::filesystem::file_time_type carouselSecondWrittenAt; + uint64_t carouselCacheHash = 0; + uint64_t carouselScreenHash = 0; + unsigned homeThemePass = 0; + uint64_t homeThemeScreenHash = 0; + std::string homeThemeBookPath; static bool enabled() { return std::getenv("CROSSINK_SIMULATOR_SMOKE_TEST") != nullptr; } @@ -750,6 +770,128 @@ class SimulatorSmokeTest { LOG_INF("SMOKE", "Legacy Home progress migration without EPUB loading passed"); } + static uint64_t hashBytes(const uint8_t* bytes, size_t size, uint64_t hash = 14695981039346656037ull) { + for (size_t i = 0; i < size; ++i) { + hash ^= bytes[i]; + hash *= 1099511628211ull; + } + return hash; + } + + static uint64_t hashCarouselCache() { + FsFile file; + if (!Storage.openFileForRead("SMOKE", "/.crosspoint/home_carousel_cache_0.bin", file)) { + fail("Carousel did not publish an artwork cache"); + } + if (file.size() < renderer.getBufferSize() || file.size() >= 2 * renderer.getBufferSize()) { + file.close(); + fail("Carousel snapshot must contain only one viewed artwork frame"); + } + uint64_t hash = 14695981039346656037ull; + uint8_t bytes[128]; + int count; + while ((count = file.read(bytes, sizeof(bytes))) > 0) hash = hashBytes(bytes, count, hash); + file.close(); + return hash; + } + + void queueCarouselSwipe(bool next) { + ++carouselCachePass; + inputScript.clear(); + scriptIndex = 0; + inputCompletionStep = SmokeStep::CarouselHome; +#if CROSSINK_APP_CAP_TOUCH + if (mappedInputManager.hasTouchHardware()) { + const int startX = renderer.getScreenWidth() * (next ? 3 : 1) / 4; + const int endX = renderer.getScreenWidth() * (next ? 1 : 3) / 4; + const int y = renderer.getScreenHeight() / 2; + inputScript.push_back(touchDown(startX, y)); + inputScript.push_back(touchMove(endX, y)); + inputScript.push_back(touchRelease(endX, y)); + } else +#endif + { + addTap(next ? MappedInputManager::Button::Right : MappedInputManager::Button::Left); + } + inputScript.push_back(render("Carousel viewed position", 8)); + step = SmokeStep::ReaderInput; + } + + void verifyCarouselCacheReturn() { + const auto writtenAt = std::filesystem::last_write_time("fs_/.crosspoint/home_carousel_cache_0.bin"); + const uint64_t cacheHash = hashCarouselCache(); + uint64_t screenHash; + { + RenderLock lock; + screenHash = hashBytes(renderer.getFrameBuffer(), renderer.getBufferSize()); + } + if (carouselCachePass == 0) { + if (Storage.exists("/.crosspoint/home_carousel_cache_1.bin") || + Storage.exists("/.crosspoint/home_carousel_cache_2.bin")) { + fail("Carousel eagerly prepared unviewed positions"); + } + carouselCacheWrittenAt = writtenAt; + carouselCacheHash = cacheHash; + const RecentBook& book = RECENT_BOOKS.getBooks().front(); + const std::string cachePath = Epub::resolveCachePathForFilePath(book.path, "/.crosspoint"); + const float oldProgress = RecentBookProgress::loadCachedEpubPercent(book); + RecentBookProgress::saveCachedEpubPercent(cachePath, oldProgress < 50.0f ? 75.0f : 25.0f); + BookReadingStats stats = BookReadingStats::load(cachePath); + stats.sessionCount = 1; + stats.totalReadingSeconds += 7200; + if (!stats.save(cachePath)) fail("Cannot save carousel stats fixture"); + } else if (carouselCachePass <= 2) { + if (writtenAt != carouselCacheWrittenAt || cacheHash != carouselCacheHash) { + fail("Reading progress/stats or tracking settings rebuilt the carousel artwork"); + } + if (screenHash == carouselScreenHash) fail("Carousel restored stale progress/stats or menu pixels"); + if (carouselCachePass == 1) { + SETTINGS.trackReadingStats = 0; + } else { + SETTINGS.screenInverted = !SETTINGS.screenInverted; + } + } else if (carouselCachePass == 3) { + if (cacheHash == carouselCacheHash) fail("Dark Mode did not invalidate carousel artwork"); + carouselCacheHash = cacheHash; + const RecentBook book = RECENT_BOOKS.getBooks().front(); + if (!RECENT_BOOKS.updateBook(book.path, "Changed carousel title", book.author, book.coverBmpPath, + book.coverState)) + fail("Cannot update carousel title fixture"); + } else if (carouselCachePass == 4) { + if (cacheHash == carouselCacheHash) fail("Title changes did not invalidate carousel artwork"); + carouselCacheHash = cacheHash; + carouselCacheWrittenAt = writtenAt; + queueCarouselSwipe(true); + return; + } else { + if (cacheHash != carouselCacheHash || writtenAt != carouselCacheWrittenAt) { + fail("Navigating the carousel rewrote an already cached position"); + } + const bool secondExists = Storage.exists("/.crosspoint/home_carousel_cache_1.bin"); + if (!secondExists || Storage.exists("/.crosspoint/home_carousel_cache_2.bin")) { + fail("Carousel navigation did not cache only the viewed position"); + } + const auto secondWrittenAt = std::filesystem::last_write_time("fs_/.crosspoint/home_carousel_cache_1.bin"); + const int selected = carouselCachePass == 5 ? 1 : 0; + if (activityManager.getCurrentBookPath() != RECENT_BOOKS.getBooks()[selected].path) { + fail("Carousel swipe did not select the expected book"); + } + if (carouselCachePass == 5) { + carouselSecondWrittenAt = secondWrittenAt; + queueCarouselSwipe(false); + return; + } + if (secondWrittenAt != carouselSecondWrittenAt) fail("Carousel rewrote the previous position while leaving it"); + LOG_INF("SMOKE", "Carousel lazy cache, live progress/stats/menu, Dark Mode and title invalidation passed"); + step = SmokeStep::Done; + return; + } + carouselScreenHash = screenHash; + ++carouselCachePass; + activityManager.goHome(); + queueStep("Carousel return cache", SmokeStep::CarouselHome, 8); + } + void tickImpl() { mappedInputManager.simulatorClearInputFrame(); @@ -765,6 +907,22 @@ class SimulatorSmokeTest { switch (step) { case SmokeStep::Start: LOG_INF("SMOKE", "Starting simulator smoke test"); + if (std::getenv("CROSSINK_SIMULATOR_SMOKE_HOME_THEMES")) { + if (!mappedInputManager.hasHomeKey() || !mappedInputManager.hasTouchHardware()) + fail("Home theme regression requires the X4 Pro simulator"); + for (const char* path : {"/books/theme-first.txt", "/books/theme-second.txt"}) { + if (!Storage.writeFile(path, "Home theme fixture")) fail("Cannot create Home theme fixture"); + RECENT_BOOKS.addOrUpdateBook(path, path, {}, {}, RecentBook::CoverState::Missing); + } + SETTINGS.uiTheme = CrossPointSettings::LYRA_CAROUSEL; + SETTINGS.uiScale = CrossPointSettings::UI_SCALE_SMALL; + UITheme::getInstance().reload(); + homeThemeBookPath = "/books/theme-first.txt"; + activityManager.replaceActivity(std::make_unique( + renderer, mappedInputManager, HomeMenuItem::NONE, HalDisplay::FAST_REFRESH, homeThemeBookPath)); + queueStep("Initial carousel Home", SmokeStep::ThemeHome, 8); + break; + } verifyLoadingPopupBackdrop(); verifyCachedHomeProgressMigration(); if (!CrossPointSettings::verifySleepTimeoutMigrationContract()) { @@ -1050,11 +1208,27 @@ class SimulatorSmokeTest { 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 + const auto down = mappedInputManager.menuButton(MappedInputManager::Button::Down); + const auto up = mappedInputManager.menuButton(MappedInputManager::Button::Up); + const auto left = mappedInputManager.menuButton(MappedInputManager::Button::Left); + const auto right = mappedInputManager.menuButton(MappedInputManager::Button::Right); + addTap(right); + inputScript.push_back(render("Settings Right selects Reader tab", 3)); + inputScript.push_back(assertSettingsNavigation(1, 0)); + addTap(down); + inputScript.push_back(render("Settings Down selects first row", 3)); + inputScript.push_back(assertSettingsNavigation(1, 1)); + addTap(left); + inputScript.push_back(render("Settings Left switches tabs while a row is focused", 3)); + inputScript.push_back(assertSettingsNavigation(0, 1)); + addTap(up); + inputScript.push_back(render("Settings Up returns to tab band", 3)); + inputScript.push_back(assertSettingsNavigation(0, 0)); + addTap(right); // Reader tab + addTap(right); // Controls tab + addTap(down); // Power Button + addTap(down); // Front Buttons + addTap(down); // Side Buttons addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("Side Button Settings", 250)); step = SmokeStep::ReaderInput; @@ -1106,12 +1280,110 @@ class SimulatorSmokeTest { runReaderInputScript(); break; + case SmokeStep::CarouselHome: + verifyCarouselCacheReturn(); + break; + + case SmokeStep::ThemeHome: { + if (activityManager.getCurrentBookPath() != homeThemeBookPath) fail("Theme switch lost the selected book"); + const int width = renderer.getScreenWidth(); + const int height = renderer.getScreenHeight(); + inputScript = {touchDown(width / 2, 8), + touchMove(width / 2, height / 4), + touchRelease(width / 2, height / 4), + render("Home frontlight drawer", 4), + assertActivity("FrontlightPanel"), + {ScriptActionType::OpenFrontlightSettings, MappedInputManager::Button::Back, nullptr, 0, 0, 0}, + render("Home drawer Settings", 4), + assertActivity("Settings")}; + scriptIndex = 0; + inputCompletionStep = SmokeStep::ThemeSettings; + step = SmokeStep::ReaderInput; + break; + } + + case SmokeStep::ThemeSettings: { + static constexpr uint8_t themes[] = {CrossPointSettings::LYRA_3_COVERS, CrossPointSettings::DASHBOARD, + CrossPointSettings::MINIMAL, CrossPointSettings::COVER_GRID, + CrossPointSettings::LYRA, CrossPointSettings::CLASSIC, + CrossPointSettings::ROUNDEDRAFF, CrossPointSettings::LYRA_CAROUSEL, + CrossPointSettings::LYRA_CAROUSEL, CrossPointSettings::LYRA_CAROUSEL}; + // Change the global values while the real Settings child is open, then + // return through its real drawer callback. Compare with a fresh Home. + { + RenderLock lock; + SETTINGS.uiTheme = themes[homeThemePass]; + if (homeThemePass == 8) SETTINGS.uiScale = CrossPointSettings::UI_SCALE_LARGE; + UITheme::getInstance().reload(); + } + inputScript = {press(MappedInputManager::Button::Back), release(MappedInputManager::Button::Back), + render("Home after drawer Settings", 8), assertActivity("Home")}; + scriptIndex = 0; + inputCompletionStep = SmokeStep::ThemeReturned; + step = SmokeStep::ReaderInput; + break; + } + + case SmokeStep::ThemeReturned: { + if (activityManager.getCurrentBookPath() != homeThemeBookPath) fail("Theme switch lost the selected book"); + { + RenderLock lock; + homeThemeScreenHash = hashBytes(renderer.getFrameBuffer(), renderer.getBufferSize()); + } + activityManager.replaceActivity(std::make_unique(renderer, mappedInputManager, HomeMenuItem::NONE, + HalDisplay::FAST_REFRESH, homeThemeBookPath)); + queueStep("Fresh Home reference", SmokeStep::ThemeFresh, 8); + break; + } + + case SmokeStep::ThemeFresh: { + { + RenderLock lock; + if (hashBytes(renderer.getFrameBuffer(), renderer.getBufferSize()) != homeThemeScreenHash) + fail("Home after drawer theme change differs from fresh Home (pass %u)", homeThemePass); + } + LOG_INF("SMOKE", "Home theme/scale return matches fresh render (pass %u)", homeThemePass); + if (++homeThemePass == 10) { + LOG_INF("SMOKE", "Simulator smoke test passed"); + std::_Exit(0); + } + step = SmokeStep::ThemeHome; + break; + } + case SmokeStep::Done: + if (SETTINGS.uiTheme == CrossPointSettings::LYRA_CAROUSEL && carouselCachePass == 0 && + std::getenv("CROSSINK_SIMULATOR_SMOKE_BOOK")) { + const RecentBook book = RECENT_BOOKS.getBooks().front(); + for (const char* path : {"/books/carousel-second.txt", "/books/carousel-third.txt"}) { + if (!Storage.writeFile(path, "Carousel side cover fixture")) fail("Cannot create carousel fixture"); + RECENT_BOOKS.addOrUpdateBook(path, path, {}, {}, RecentBook::CoverState::Missing); + } + RECENT_BOOKS.addOrUpdateBook(book.path, book.title, book.author, book.coverBmpPath, book.coverState); + SETTINGS.trackReadingStats = 1; + activityManager.goHome(); + queueStep("Carousel return cache", SmokeStep::CarouselHome, 8); + break; + } LOG_INF("SMOKE", "Simulator smoke test passed"); std::_Exit(0); } } + static ScriptAction assertReaderMenu(const ReaderDrawerTab tab, const ReaderDrawerPane pane, const int selected) { + return {ScriptActionType::AssertReaderMenu, + MappedInputManager::Button::Back, + nullptr, + selected, + static_cast(tab), + static_cast(pane)}; + } + + static ScriptAction assertSettingsNavigation(const int category, const int selected) { + return { + ScriptActionType::AssertSettingsNavigation, MappedInputManager::Button::Back, nullptr, 0, category, selected}; + } + static ScriptAction press(MappedInputManager::Button button) { return {ScriptActionType::Press, button, nullptr, 0, 0, 0}; } @@ -1172,6 +1444,10 @@ class SimulatorSmokeTest { return {ScriptActionType::Render, MappedInputManager::Button::Back, label, framesToSettle, 0, 0}; } + static ScriptAction assertActivity(const char* name) { + return {ScriptActionType::AssertActivity, MappedInputManager::Button::Back, name, 0, 0, 0}; + } + #if CROSSINK_APP_CAP_TOUCH static ScriptAction touchDown(const int x, const int y) { return {ScriptActionType::TouchDown, MappedInputManager::Button::Back, nullptr, 0, x, y}; @@ -1182,9 +1458,7 @@ class SimulatorSmokeTest { static ScriptAction touchRelease(const int x, const int y) { return {ScriptActionType::TouchRelease, MappedInputManager::Button::Back, nullptr, 0, x, y}; } - static ScriptAction assertActivity(const char* name) { - return {ScriptActionType::AssertActivity, MappedInputManager::Button::Back, name, 0, 0, 0}; - } + #endif void addTap(MappedInputManager::Button button) { @@ -1197,6 +1471,21 @@ class SimulatorSmokeTest { scriptIndex = 0; inputCompletionStep = SmokeStep::Done; + int currentFontIndex = SETTINGS.fontFamily; + std::vector currentFontSizes(std::begin(BUILTIN_READER_FONT_SIZES), std::end(BUILTIN_READER_FONT_SIZES)); + if (SETTINGS.sdFontFamilyName[0] != '\0') { + sdFontSystem.ensureRegistry(); + const auto& families = sdFontSystem.registry().getFamilies(); + for (size_t i = 0; i < families.size(); ++i) { + if (families[i].name != SETTINGS.sdFontFamilyName) continue; + currentFontIndex = CrossPointSettings::BUILTIN_FONT_COUNT + static_cast(i); + currentFontSizes = families[i].availableSizes(); + break; + } + } + const auto sizeIt = std::find(currentFontSizes.begin(), currentFontSizes.end(), SETTINGS.readerFontPointSize); + const int currentSizeIndex = sizeIt == currentFontSizes.end() ? 0 : std::distance(currentFontSizes.begin(), sizeIt); + const int turns = pageTurnCount(); #if CROSSINK_APP_CAP_TOUCH if (mappedInputManager.hasTouch()) { @@ -1223,14 +1512,18 @@ class SimulatorSmokeTest { inputScript.push_back(touchDown(width / (static_cast(READER_DRAWER_TAB_COUNT) * 2), tabY)); inputScript.push_back(touchRelease(width / (static_cast(READER_DRAWER_TAB_COUNT) * 2), tabY)); addTap(MappedInputManager::Button::Confirm); - addTap(MappedInputManager::Button::Down); - addTap(MappedInputManager::Button::Down); + addTap(mappedInputManager.menuButton(MappedInputManager::Button::Down)); + addTap(mappedInputManager.menuButton(MappedInputManager::Button::Down)); addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("TTF Rendering opened in reader drawer", 4)); inputScript.push_back(assertActivity("EpubReaderDrawer")); addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("TTF Hinting choices opened in reader drawer", 3)); - addTap(MappedInputManager::Button::Down); + const int currentHinting = TTF_RENDER_PROFILES.profileFor(SETTINGS.sdFontFamilyName).hinting; + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Font, ReaderDrawerPane::EnumOptions, currentHinting)); + const auto hintingDirection = mappedInputManager.menuButton( + currentHinting > 1 ? MappedInputManager::Button::Up : MappedInputManager::Button::Down); + for (int i = 0; i < std::abs(currentHinting - 1); ++i) addTap(hintingDirection); addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("TTF Native hinting selected", 4)); inputScript.push_back(assertTtfProfileNative()); @@ -1396,6 +1689,22 @@ class SimulatorSmokeTest { inputScript.push_back(render("Reader Menu opened from touch gesture", 4)); inputScript.push_back(assertActivity("EpubReaderDrawer")); + const int fontTabX = width / (static_cast(READER_DRAWER_TAB_COUNT) * 2); + inputScript.push_back(touchDown(fontTabX, tabY)); + inputScript.push_back(touchRelease(fontTabX, tabY)); + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("Touch Reader Font choices", 3)); + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("Touch Font Family opens on current choice", 4)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Font, ReaderDrawerPane::FontFamily, currentFontIndex)); + addTap(MappedInputManager::Button::Back); + addTap(mappedInputManager.menuButton(MappedInputManager::Button::Down)); + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("Touch Font Size opens on current choice", 4)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Font, ReaderDrawerPane::EnumOptions, currentSizeIndex)); + addTap(MappedInputManager::Button::Back); + addTap(MappedInputManager::Button::Back); + // Touch every bottom-drawer tab slot, then dismiss from its handle. for (int tab = 0; tab < static_cast(READER_DRAWER_TAB_COUNT); ++tab) { const int tabX = width * (tab * 2 + 1) / (static_cast(READER_DRAWER_TAB_COUNT) * 2); @@ -1454,6 +1763,9 @@ class SimulatorSmokeTest { return; } #endif + const auto menuDown = mappedInputManager.menuButton(MappedInputManager::Button::Down); + const auto menuLeft = mappedInputManager.menuButton(MappedInputManager::Button::Left); + const auto menuRight = mappedInputManager.menuButton(MappedInputManager::Button::Right); for (int i = 0; i < turns; i++) { addTap(MappedInputManager::Button::PageForward); inputScript.push_back(render("Reader after page forward", 4)); @@ -1461,21 +1773,57 @@ class SimulatorSmokeTest { addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("Reader Menu opened from EPUB", 4)); - - addTap(MappedInputManager::Button::Down); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::More, ReaderDrawerPane::Root, 0)); + // Select retains its original tab-band behavior, including wraparound. + for (const auto tab : {ReaderDrawerTab::Location, ReaderDrawerTab::Settings, ReaderDrawerTab::Font, + ReaderDrawerTab::Layout, ReaderDrawerTab::More}) { + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("Reader Menu Select advances tab", 3)); + inputScript.push_back(assertReaderMenu(tab, ReaderDrawerPane::Root, 0)); + } + addTap(menuLeft); + addTap(menuLeft); + inputScript.push_back(render("Reader Menu Font tab reached with Left", 3)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Font, ReaderDrawerPane::Root, 0)); + addTap(menuLeft); + inputScript.push_back(render("Reader Menu wraps left to Settings", 3)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Settings, ReaderDrawerPane::Root, 0)); + addTap(menuRight); + inputScript.push_back(render("Reader Menu wraps right to Font", 3)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Font, ReaderDrawerPane::Root, 0)); + + addTap(menuDown); inputScript.push_back(render("Reader Menu first row focused", 3)); + addTap(menuDown); + inputScript.push_back(render("Reader Menu Down moves row focus", 3)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Font, ReaderDrawerPane::Root, 1)); + addTap(menuRight); + inputScript.push_back(render("Reader Menu Right switches tab while a row is focused", 3)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Layout, ReaderDrawerPane::Root, 0)); + addTap(menuLeft); + addTap(menuDown); + inputScript.push_back(render("Reader Menu first Font row focused again", 3)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Font, ReaderDrawerPane::Root, 0)); addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("Reader Font opened from Reader Menu", 4)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Font, ReaderDrawerPane::ReaderFont, 0)); - addTap(MappedInputManager::Button::Down); + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("Font Family opens on current choice", 4)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Font, ReaderDrawerPane::FontFamily, currentFontIndex)); + addTap(MappedInputManager::Button::Back); + inputScript.push_back(render("Reader Font after closing Font Family", 3)); + + addTap(menuDown); inputScript.push_back(render("Font Size selected", 3)); addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("Font Size choices opened", 3)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Font, ReaderDrawerPane::EnumOptions, currentSizeIndex)); #if CROSSINK_APP_READER_SAMPLE_PREVIEW - addTap(MappedInputManager::Button::Down); + addTap(menuDown); addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("C3 font size paragraph preview", 4)); #endif @@ -1486,7 +1834,7 @@ class SimulatorSmokeTest { #if CROSSINK_APP_READER_SAMPLE_PREVIEW addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("C3 font family picker", 4)); - addTap(MappedInputManager::Button::Down); + addTap(menuDown); addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("C3 font family paragraph preview", 4)); addTap(MappedInputManager::Button::Back); @@ -1497,16 +1845,16 @@ class SimulatorSmokeTest { inputScript.push_back(render("Reader Menu tab focus restored", 4)); #if CROSSINK_APP_READER_SAMPLE_PREVIEW - addTap(MappedInputManager::Button::Down); - addTap(MappedInputManager::Button::Down); - addTap(MappedInputManager::Button::Down); + addTap(menuDown); + addTap(menuDown); + addTap(menuDown); addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("C3 spacing paragraph preview", 4)); addTap(MappedInputManager::Button::Confirm); addTap(MappedInputManager::Button::Down); inputScript.push_back(render("C3 line spacing adjusted", 4)); addTap(MappedInputManager::Button::Back); - addTap(MappedInputManager::Button::Down); + addTap(menuDown); addTap(MappedInputManager::Button::Confirm); addTap(MappedInputManager::Button::Down); inputScript.push_back(render("C3 word spacing adjusted", 4)); @@ -1515,18 +1863,19 @@ class SimulatorSmokeTest { inputScript.push_back(render("C3 font tab restored", 3)); #endif - addTap(MappedInputManager::Button::Confirm); + addTap(menuRight); inputScript.push_back(render("Reader Menu advanced to next tab", 4)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::Layout, ReaderDrawerPane::Root, 0)); #if CROSSINK_APP_READER_SAMPLE_PREVIEW - addTap(MappedInputManager::Button::Down); + addTap(menuDown); addTap(MappedInputManager::Button::Confirm); inputScript.push_back(render("C3 margin paragraph preview", 4)); addTap(MappedInputManager::Button::Confirm); addTap(MappedInputManager::Button::Down); inputScript.push_back(render("C3 vertical margin adjusted", 4)); addTap(MappedInputManager::Button::Back); - addTap(MappedInputManager::Button::Down); + addTap(menuDown); addTap(MappedInputManager::Button::Confirm); addTap(MappedInputManager::Button::Down); inputScript.push_back(render("C3 horizontal margin adjusted", 4)); @@ -1537,6 +1886,12 @@ class SimulatorSmokeTest { addTap(MappedInputManager::Button::Back); inputScript.push_back(render("Reader after closing Reader Menu", 4)); + inputScript.push_back(assertActivity("EpubReader")); + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("Reader Menu reopened on More", 4)); + inputScript.push_back(assertReaderMenu(ReaderDrawerTab::More, ReaderDrawerPane::Root, 0)); + addTap(MappedInputManager::Button::Back); + inputScript.push_back(render("Reader restored after checking default tab", 4)); LOG_INF("SMOKE", "Running reader input script with %d page turn(s)", turns); } @@ -1604,6 +1959,12 @@ class SimulatorSmokeTest { const auto& action = inputScript[scriptIndex++]; switch (action.type) { + case ScriptActionType::OpenFrontlightSettings: { + auto* panel = dynamic_cast(activityManager.simulatorCurrentActivity()); + if (!panel) fail("Expected frontlight drawer before opening Settings"); + panel->simulatorActivateQuickAction(3); + break; + } case ScriptActionType::Press: mappedInputManager.simulatorInjectPress(action.button); break; @@ -1672,6 +2033,28 @@ class SimulatorSmokeTest { mappedInputManager.simulatorInjectTouchRelease(action.x, action.y); #endif break; + case ScriptActionType::AssertReaderMenu: { + if (!activityManager.isCurrentActivityNamed("EpubReaderDrawer")) + fail("Expected reader menu for navigation assertion"); + const auto* drawer = static_cast(activityManager.simulatorCurrentActivity()); + const auto& state = drawer->simulatorState(); + if ((state.pane == ReaderDrawerPane::FontFamily || state.pane == ReaderDrawerPane::EnumOptions) && + !drawer->simulatorFocusedRowVisible()) + fail("Reader picker current choice is not highlighted and visible"); + if (static_cast(state.tab) != action.x || static_cast(state.pane) != action.y || + state.selectedIndex != action.settleFrames) + fail("Reader menu navigation mismatch: tab=%d pane=%d row=%d, expected %d/%d/%d", static_cast(state.tab), + static_cast(state.pane), state.selectedIndex, action.x, action.y, action.settleFrames); + break; + } + case ScriptActionType::AssertSettingsNavigation: { + if (!activityManager.isCurrentActivityNamed("Settings")) fail("Expected settings for navigation assertion"); + const auto* settings = static_cast(activityManager.simulatorCurrentActivity()); + if (settings->simulatorCategoryIndex() != action.x || settings->simulatorSelectedIndex() != action.y) + fail("Settings navigation mismatch: category=%d row=%d, expected %d/%d", settings->simulatorCategoryIndex(), + settings->simulatorSelectedIndex(), action.x, action.y); + break; + } case ScriptActionType::AssertActivity: if (!activityManager.isCurrentActivityNamed(action.label)) fail("Expected current activity: %s", action.label); break; diff --git a/src/util/BookCacheUtils.cpp b/src/util/BookCacheUtils.cpp index 4a84a5c850f..08c3a1897a4 100644 --- a/src/util/BookCacheUtils.cpp +++ b/src/util/BookCacheUtils.cpp @@ -280,7 +280,7 @@ bool recoverInterruptedPreservation(const std::string& cachePath, const Preserve // Stats moved out of the cache are absent from the cache-directory scan in // resolvePreservedFiles(). Find their temporary siblings before that scan. const size_t slash = cachePath.find_last_of('/'); - if (slash == std::string::npos || !statsTmpPrefix) return false; + if (slash == std::string::npos) return false; const std::string parentPath = slash == 0 ? "/" : cachePath.substr(0, slash); const std::string prefix = cachePath.substr(slash + 1) + "." + statsTmpPrefix; FsFile parent = Storage.open(parentPath.c_str()); diff --git a/src/util/BookMoveUtils.cpp b/src/util/BookMoveUtils.cpp index 6ece8211613..35fe6bebcb3 100644 --- a/src/util/BookMoveUtils.cpp +++ b/src/util/BookMoveUtils.cpp @@ -1,12 +1,15 @@ #include "BookMoveUtils.h" #include +#include #include #include #include #include +#include #include +#include #include #include "BookmarkStore.h" @@ -17,6 +20,13 @@ namespace { constexpr char READ_FOLDER[] = "/Read"; +const char* bookTypeForPath(const std::string& path) { + if (FsHelpers::hasEpubExtension(path)) return "epub"; + if (FsHelpers::hasXtcExtension(path)) return "xtc"; + if (FsHelpers::hasTxtExtension(path) || FsHelpers::hasMarkdownExtension(path)) return "txt"; + return nullptr; +} + bool getCachePath(const std::string& bookPath, const char* bookType, std::string& cachePath) { if (strcmp(bookType, "epub") == 0) { cachePath = Epub::cachePathForFilePath(bookPath, "/.crosspoint"); @@ -34,6 +44,56 @@ bool getCachePath(const std::string& bookPath, const char* bookType, std::string namespace BookMoveUtils { +RenameMigrationResult renameFilePreservingBookState(const std::string& oldPath, const std::string& newPath) { + const char* bookType = bookTypeForPath(oldPath); + if (!bookType) { + if (!Storage.rename(oldPath.c_str(), newPath.c_str())) { + LOG_ERR("BookMove", "Failed to rename file: %s -> %s", oldPath.c_str(), newPath.c_str()); + return RenameMigrationResult::RolledBack; + } + const bool moveSleepImage = APP_STATE.favoriteSleepImagePath == oldPath; + const bool moveBootImage = APP_STATE.favoriteBootImagePath == oldPath; + if (!moveSleepImage && !moveBootImage) return RenameMigrationResult::Success; + if (moveSleepImage) APP_STATE.favoriteSleepImagePath = newPath; + if (moveBootImage) APP_STATE.favoriteBootImagePath = newPath; + if (APP_STATE.saveToFile()) return RenameMigrationResult::Success; + LOG_ERR("BookMove", "Failed to save renamed favorite image path: %s", newPath.c_str()); + if (!Storage.rename(newPath.c_str(), oldPath.c_str())) { + LOG_ERR("BookMove", "Failed to restore original favorite image filename: %s", oldPath.c_str()); + return RenameMigrationResult::KeepRenamed; + } + if (moveSleepImage) APP_STATE.favoriteSleepImagePath = oldPath; + if (moveBootImage) APP_STATE.favoriteBootImagePath = oldPath; + if (!APP_STATE.saveToFile()) + LOG_ERR("BookMove", "Failed to restore favorite image references: %s", oldPath.c_str()); + return RenameMigrationResult::RolledBack; + } + + const char* newBookType = bookTypeForPath(newPath); + if (!newBookType || strcmp(bookType, newBookType) != 0) { + LOG_ERR("BookMove", "Rename would change book format: %s -> %s", oldPath.c_str(), newPath.c_str()); + return RenameMigrationResult::InvalidBookType; + } + + std::string oldCachePath; + if (!getCachePath(oldPath, bookType, oldCachePath)) return RenameMigrationResult::RolledBack; + + std::string title = oldPath.substr(oldPath.find_last_of('/') + 1); + const auto extension = title.rfind('.'); + if (extension != std::string::npos) title.resize(extension); + utf8ComposeNfcInPlace(title.data()); + title.resize(strlen(title.c_str())); + std::string author; + const auto& recentBooks = RECENT_BOOKS.getBooks(); + const auto recent = std::find_if(recentBooks.begin(), recentBooks.end(), + [&oldPath](const RecentBook& book) { return book.path == oldPath; }); + if (recent != recentBooks.end()) { + if (!recent->title.empty()) title = recent->title; + author = recent->author; + } + return migrateRenamedBookState(oldPath, newPath, oldCachePath, title, author, bookType); +} + std::string buildReadFolderDestination(const std::string& srcPath) { const size_t lastSlash = srcPath.rfind('/'); const std::string filename = (lastSlash != std::string::npos) ? srcPath.substr(lastSlash + 1) : srcPath; @@ -66,6 +126,17 @@ RenameMigrationResult migrateRenamedBookState(const std::string& oldPath, const std::string newCachePath; if (!getCachePath(newPath, bookType, newCachePath)) return RenameMigrationResult::RolledBack; + // A deleted book may leave saved data at this filename. Reject it before + // staging anything, even if the source book has never created a cache. + const auto& recentBooks = RECENT_BOOKS.getBooks(); + if (Storage.exists(newCachePath.c_str()) || BookmarkStore::hasStoredStateForFilePath(newPath, bookType) || + (strcmp(bookType, "epub") == 0 && ClippingStore::hasStoredStateForFilePath(newPath, bookType)) || + std::any_of(recentBooks.begin(), recentBooks.end(), + [&newPath](const RecentBook& book) { return book.path == newPath; })) { + LOG_ERR("BookMove", "Rename target already has saved reader state: %s", newPath.c_str()); + return RenameMigrationResult::DestinationStateExists; + } + bool cacheMoved = false; bool bookRenamed = false; bool recentMoved = false; diff --git a/src/util/BookMoveUtils.h b/src/util/BookMoveUtils.h index 019e233d8ac..01485bcb6d1 100644 --- a/src/util/BookMoveUtils.h +++ b/src/util/BookMoveUtils.h @@ -8,9 +8,15 @@ enum class RenameMigrationResult { Success, RolledBack, KeepRenamed, + InvalidBookType, + DestinationStateExists, }; std::string buildReadFolderDestination(const std::string& srcPath); +// Renames a file and migrates supported books' path-based reader state. Book +// renames must keep the same reader format. Other files also keep pinned image +// references, with rollback if those references cannot be saved. +RenameMigrationResult renameFilePreservingBookState(const std::string& oldPath, const std::string& newPath); // Prepares reader metadata, renames the physical book, then commits the state // migration so one canonical metadata path is always available across resets. RenameMigrationResult migrateRenamedBookState(const std::string& oldPath, const std::string& newPath, diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e9740222fcd..894c8bcb078 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -103,6 +103,7 @@ add_subdirectory(library_format) add_subdirectory(library_index_file) add_subdirectory(content_opf_parser) add_subdirectory(library_builder) +add_subdirectory(library_input_buffer) add_subdirectory(library_recent_order) add_subdirectory(scalable_fonts) diff --git a/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp b/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp index 4406a46ece3..081d69ab871 100644 --- a/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp +++ b/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp @@ -43,8 +43,121 @@ class ChapterHtmlSlimParserTest : public ::testing::TestWithParam { parser.blockStyleBuf_ = blockStyles.data(); parser.blockStyleCount_ = 1; } + + BlockStyle parseParagraph(const char* className) { + const XML_Char* attributes[] = {"class", className, nullptr}; + ChapterHtmlSlimParser::startElement(&parser, "p", attributes); + const BlockStyle style = parser.currentTextBlock->getBlockStyle(); + ChapterHtmlSlimParser::characterData(&parser, "Text", 4); + ChapterHtmlSlimParser::endElement(&parser, "p"); + return style; + } }; +TEST_F(ChapterHtmlSlimParserTest, InheritsBodyTextIndentAndPreservesExplicitParagraphZero) { + parser.cssParser->rulesBySelector_[".class-0"] = + CssParser::parseInlineStyle("text-indent: 1.5em; text-align: justify"); + parser.cssParser->rulesBySelector_[".class_s4K-0"] = CssParser::parseInlineStyle("text-indent: 0"); + parser.cssParser->rulesBySelector_[".class_s4P-0"] = CssParser::parseInlineStyle("margin-top: 0; margin-bottom: 0"); + + const XML_Char* bodyAttributes[] = {"class", "class-0", nullptr}; + ChapterHtmlSlimParser::startElement(&parser, "body", bodyAttributes); + + const BlockStyle openingParagraph = parseParagraph("class_s4K-0"); + EXPECT_TRUE(openingParagraph.textIndentDefined); + EXPECT_EQ(openingParagraph.textIndent, 0); + EXPECT_EQ(parser.currentTextBlock->resolveFirstLineIndent(true, renderer, 0), 0); + + const BlockStyle followingParagraph = parseParagraph("class_s4P-0"); + EXPECT_TRUE(followingParagraph.textIndentDefined); + EXPECT_EQ(followingParagraph.textIndent, 18); + EXPECT_EQ(parser.currentTextBlock->resolveFirstLineIndent(true, renderer, 0), 18); + + ChapterHtmlSlimParser::endElement(&parser, "body"); +} + +TEST_F(ChapterHtmlSlimParserTest, HtmlIndentFlowsThroughBodyAndBodyIndentOverridesIt) { + parser.cssParser->rulesBySelector_[".html-indent"] = CssParser::parseInlineStyle("text-indent: 1em"); + parser.cssParser->rulesBySelector_[".body-indent"] = CssParser::parseInlineStyle("text-indent: 1.5em"); + parser.cssParser->rulesBySelector_[".plain"] = CssParser::parseInlineStyle("margin: 0"); + + const XML_Char* htmlAttributes[] = {"class", "html-indent", nullptr}; + ChapterHtmlSlimParser::startElement(&parser, "html", htmlAttributes); + EXPECT_EQ(parser.blockStyleBuf_[0].textIndent, 12); + + const XML_Char* bodyAttributes[] = {"class", "body-indent", nullptr}; + ChapterHtmlSlimParser::startElement(&parser, "body", bodyAttributes); + + const BlockStyle inheritedParagraph = parseParagraph("plain"); + EXPECT_TRUE(inheritedParagraph.textIndentDefined); + EXPECT_EQ(inheritedParagraph.textIndent, 18); + EXPECT_EQ(parser.currentTextBlock->resolveFirstLineIndent(true, renderer, 0), 18); + + ChapterHtmlSlimParser::endElement(&parser, "body"); + ChapterHtmlSlimParser::endElement(&parser, "html"); +} + +TEST_F(ChapterHtmlSlimParserTest, InheritsTextIndentFromDivAndKeepsParagraphOverride) { + parser.cssParser->rulesBySelector_[".ancestor-indent"] = CssParser::parseInlineStyle("text-indent: 1.5em"); + parser.cssParser->rulesBySelector_[".plain"] = CssParser::parseInlineStyle("margin: 0"); + parser.cssParser->rulesBySelector_[".zero"] = CssParser::parseInlineStyle("text-indent: 0"); + + const XML_Char* divAttributes[] = {"class", "ancestor-indent", nullptr}; + ChapterHtmlSlimParser::startElement(&parser, "div", divAttributes); + + const BlockStyle inheritedParagraph = parseParagraph("plain"); + EXPECT_TRUE(inheritedParagraph.textIndentDefined); + EXPECT_EQ(inheritedParagraph.textIndent, 18); + EXPECT_EQ(parser.currentTextBlock->resolveFirstLineIndent(true, renderer, 0), 18); + + const BlockStyle zeroParagraph = parseParagraph("zero"); + EXPECT_TRUE(zeroParagraph.textIndentDefined); + EXPECT_EQ(zeroParagraph.textIndent, 0); + EXPECT_EQ(parser.currentTextBlock->resolveFirstLineIndent(true, renderer, 0), 0); + + ChapterHtmlSlimParser::endElement(&parser, "div"); +} + +TEST_F(ChapterHtmlSlimParserTest, BodyIndentRespectsSpacingAndForcedIndentSettings) { + parser.cssParser->rulesBySelector_[".body-indent"] = CssParser::parseInlineStyle("text-indent: 1.5em"); + parser.cssParser->rulesBySelector_[".plain"] = CssParser::parseInlineStyle("margin: 0"); + parser.cssParser->rulesBySelector_[".zero"] = CssParser::parseInlineStyle("text-indent: 0"); + const XML_Char* bodyAttributes[] = {"class", "body-indent", nullptr}; + ChapterHtmlSlimParser::startElement(&parser, "body", bodyAttributes); + + parser.extraParagraphSpacing = true; + parser.currentTextBlock = std::make_unique(true, false); + const BlockStyle spacedParagraph = parseParagraph("plain"); + EXPECT_EQ(spacedParagraph.textIndent, 18); + EXPECT_EQ(parser.currentTextBlock->resolveFirstLineIndent(true, renderer, 0), 0); + + parser.extraParagraphSpacing = false; + const BlockStyle unspacedParagraph = parseParagraph("plain"); + EXPECT_EQ(unspacedParagraph.textIndent, 18); + EXPECT_EQ(parser.currentTextBlock->resolveFirstLineIndent(true, renderer, 0), 18); + + parser.forceParagraphIndents = true; + const BlockStyle forcedZeroParagraph = parseParagraph("zero"); + EXPECT_EQ(forcedZeroParagraph.textIndent, renderer.getFontAscenderSize(0)); + EXPECT_EQ(parser.currentTextBlock->resolveFirstLineIndent(true, renderer, 0), renderer.getFontAscenderSize(0)); + + ChapterHtmlSlimParser::endElement(&parser, "body"); +} + +TEST_F(ChapterHtmlSlimParserTest, RootIndentIsIgnoredWhenEmbeddedStyleIsOff) { + parser.embeddedStyle = false; + parser.cssParser->rulesBySelector_[".body-indent"] = CssParser::parseInlineStyle("text-indent: 1.5em"); + parser.cssParser->rulesBySelector_[".plain"] = CssParser::parseInlineStyle("margin: 0"); + const XML_Char* bodyAttributes[] = {"class", "body-indent", nullptr}; + ChapterHtmlSlimParser::startElement(&parser, "body", bodyAttributes); + + const BlockStyle plainParagraph = parseParagraph("plain"); + EXPECT_FALSE(plainParagraph.textIndentDefined); + EXPECT_EQ(parser.currentTextBlock->resolveFirstLineIndent(true, renderer, 0), 0); + + ChapterHtmlSlimParser::endElement(&parser, "body"); +} + TEST_P(ChapterHtmlSlimParserTest, KeepsCssVerticalAlignAndInternalLinkMetadata) { const char* verticalAlign = GetParam(); const char* expectedHref = "#note-target"; @@ -116,6 +229,130 @@ TEST_F(ChapterHtmlSlimParserTest, UsesOptimizerImageDimensionsWithoutReadingTheC const auto& image = static_cast(*parser.currentPage->elements.front()).getImageBlock(); EXPECT_EQ(image.getWidth(), 480); EXPECT_EQ(image.getHeight(), 4); + EXPECT_FALSE(static_cast(*parser.currentPage->elements.front()).isInlineImage()); +} + +TEST_F(ChapterHtmlSlimParserTest, PlacesSmallImageInsideTextLine) { + epub.optimizerImageAvailable = true; + epub.optimizerImageWidth = 16; + epub.optimizerImageHeight = 16; + const XML_Char* attributes[] = {"src", "icon.jpg", nullptr}; + + ChapterHtmlSlimParser::characterData(&parser, "Before", 6); + ChapterHtmlSlimParser::startElement(&parser, "img", attributes); + ChapterHtmlSlimParser::endElement(&parser, "img"); + ChapterHtmlSlimParser::characterData(&parser, "After", 5); + parser.flushPartWordBuffer(); + parser.makePages(); + + ASSERT_NE(parser.currentPage, nullptr); + ASSERT_EQ(parser.currentPage->elements.size(), 2u); + EXPECT_EQ(parser.currentPage->elements[0]->getTag(), TAG_PageLine); + EXPECT_EQ(parser.currentPage->elements[1]->getTag(), TAG_PageImage); + const auto& line = static_cast(*parser.currentPage->elements[0]); + EXPECT_EQ(line.getBlock()->wordCount(), 2u); + const auto& image = static_cast(*parser.currentPage->elements[1]); + EXPECT_TRUE(image.isInlineImage()); + EXPECT_EQ(image.yPos, parser.currentPage->elements[0]->yPos); + EXPECT_EQ(image.getImageBlock().getWidth(), 16); + EXPECT_TRUE(parser.pendingInlineImages.empty()); +} + +TEST_F(ChapterHtmlSlimParserTest, WrapsTextAfterInlineImageWithoutSplittingTheImage) { + renderer.textAdvancePerChar = 4; + parser.viewportWidth = 22; + epub.optimizerImageAvailable = true; + epub.optimizerImageWidth = 16; + epub.optimizerImageHeight = 16; + const XML_Char* attributes[] = {"src", "icon.jpg", nullptr}; + + ChapterHtmlSlimParser::characterData(&parser, "A", 1); + ChapterHtmlSlimParser::startElement(&parser, "img", attributes); + ChapterHtmlSlimParser::endElement(&parser, "img"); + ChapterHtmlSlimParser::characterData(&parser, "B", 1); + parser.flushPartWordBuffer(); + parser.makePages(); + + ASSERT_NE(parser.currentPage, nullptr); + ASSERT_EQ(parser.currentPage->elements.size(), 3u); + EXPECT_EQ(parser.currentPage->elements[0]->getTag(), TAG_PageLine); + EXPECT_EQ(parser.currentPage->elements[1]->getTag(), TAG_PageImage); + EXPECT_EQ(parser.currentPage->elements[2]->getTag(), TAG_PageLine); + EXPECT_EQ(parser.currentPage->elements[1]->xPos, 4); + EXPECT_EQ(parser.currentPage->elements[0]->yPos, parser.currentPage->elements[1]->yPos); + EXPECT_EQ(parser.currentPage->elements[2]->yPos, 16); +} + +TEST_F(ChapterHtmlSlimParserTest, AlignsTextWithTallerInlineImage) { + epub.optimizerImageAvailable = true; + epub.optimizerImageWidth = 20; + epub.optimizerImageHeight = 32; + const XML_Char* attributes[] = {"src", "icon.jpg", nullptr}; + + ChapterHtmlSlimParser::characterData(&parser, "A", 1); + ChapterHtmlSlimParser::startElement(&parser, "img", attributes); + ChapterHtmlSlimParser::endElement(&parser, "img"); + parser.makePages(); + + ASSERT_EQ(parser.currentPage->elements.size(), 2u); + EXPECT_EQ(parser.currentPage->elements[0]->getTag(), TAG_PageLine); + EXPECT_EQ(parser.currentPage->elements[1]->getTag(), TAG_PageImage); + EXPECT_EQ(parser.currentPage->elements[0]->yPos, 16); + EXPECT_EQ(parser.currentPage->elements[1]->yPos, 0); + EXPECT_EQ(parser.currentPageNextY, 32); +} + +TEST_F(ChapterHtmlSlimParserTest, HonorsExplicitBlockDisplayForSmallImage) { + epub.optimizerImageAvailable = true; + epub.optimizerImageWidth = 16; + epub.optimizerImageHeight = 16; + const XML_Char* attributes[] = {"src", "icon.jpg", "style", "display: block", nullptr}; + + ChapterHtmlSlimParser::characterData(&parser, "A", 1); + ChapterHtmlSlimParser::startElement(&parser, "img", attributes); + + ASSERT_NE(parser.currentPage, nullptr); + ASSERT_EQ(parser.currentPage->elements.size(), 2u); + EXPECT_EQ(parser.currentPage->elements[0]->getTag(), TAG_PageLine); + EXPECT_EQ(parser.currentPage->elements[1]->getTag(), TAG_PageImage); + EXPECT_GE(parser.currentPage->elements[1]->yPos, 16); +} + +TEST_F(ChapterHtmlSlimParserTest, HonorsExplicitInlineDisplayForTallerImage) { + epub.optimizerImageAvailable = true; + epub.optimizerImageWidth = 20; + epub.optimizerImageHeight = 48; + const XML_Char* attributes[] = {"src", "icon.jpg", "style", "display: inline", nullptr}; + + ChapterHtmlSlimParser::characterData(&parser, "A", 1); + ChapterHtmlSlimParser::startElement(&parser, "img", attributes); + ChapterHtmlSlimParser::endElement(&parser, "img"); + parser.makePages(); + + ASSERT_EQ(parser.currentPage->elements.size(), 2u); + EXPECT_EQ(parser.currentPage->elements[0]->getTag(), TAG_PageLine); + EXPECT_EQ(parser.currentPage->elements[1]->getTag(), TAG_PageImage); + EXPECT_EQ(parser.currentPage->elements[0]->yPos, 32); + EXPECT_EQ(parser.currentPage->elements[1]->yPos, 0); + EXPECT_EQ(parser.currentPageNextY, 48); +} + +TEST_F(ChapterHtmlSlimParserTest, BoundsPendingImagesInAnIconOnlyParagraph) { + epub.optimizerImageAvailable = true; + epub.optimizerImageWidth = 1; + epub.optimizerImageHeight = 1; + const XML_Char* attributes[] = {"src", "icon.jpg", nullptr}; + + for (int i = 0; i < 40; ++i) { + ChapterHtmlSlimParser::startElement(&parser, "img", attributes); + ChapterHtmlSlimParser::endElement(&parser, "img"); + EXPECT_LT(parser.pendingInlineImages.size(), parser.MAX_PENDING_INLINE_IMAGES); + } + parser.makePages(); + + ASSERT_NE(parser.currentPage, nullptr); + EXPECT_TRUE(parser.pendingInlineImages.empty()); + EXPECT_EQ(parser.currentPage->elements.size(), 40u); } TEST_F(ChapterHtmlSlimParserTest, HiddenElementsSuppressContentAndResumeVisibleText) { diff --git a/test/chapter_html_slim_parser/ParserLinkStubs.cpp b/test/chapter_html_slim_parser/ParserLinkStubs.cpp index 3e0e67b6e9b..7d88413f9a2 100644 --- a/test/chapter_html_slim_parser/ParserLinkStubs.cpp +++ b/test/chapter_html_slim_parser/ParserLinkStubs.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include diff --git a/test/chapter_html_slim_parser/stubs/GfxRenderer.h b/test/chapter_html_slim_parser/stubs/GfxRenderer.h index bc94c0c51c1..05348aded3e 100644 --- a/test/chapter_html_slim_parser/stubs/GfxRenderer.h +++ b/test/chapter_html_slim_parser/stubs/GfxRenderer.h @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -12,10 +13,13 @@ enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 }; class GfxRenderer { public: + int textAdvancePerChar = 0; int getFontAscenderSize(int) const { return 12; } int getLineHeight(int) const { return 16; } int getTextWidth(int, const char*, EpdFontFamily::Style = EpdFontFamily::REGULAR) const { return 0; } - int getTextAdvanceX(int, const char*, EpdFontFamily::Style, uint32_t = 0) const { return 0; } + int getTextAdvanceX(int, const char* text, EpdFontFamily::Style, uint32_t = 0) const { + return static_cast(std::strlen(text)) * textAdvancePerChar; + } int getSpaceWidth(int, EpdFontFamily::Style) const { return 0; } int getKerning(int, uint32_t, uint32_t, EpdFontFamily::Style) const { return 0; } int getSpaceAdvance(int, uint32_t, uint32_t, EpdFontFamily::Style) const { return 0; } diff --git a/test/content_opf_parser/ContentOpfParserTest.cpp b/test/content_opf_parser/ContentOpfParserTest.cpp index b07cff037aa..68a1f1507e7 100644 --- a/test/content_opf_parser/ContentOpfParserTest.cpp +++ b/test/content_opf_parser/ContentOpfParserTest.cpp @@ -3,13 +3,14 @@ #include #include "ContentOpfParser.h" +#include "Epub/BookMetadataCache.h" namespace { -// ContentOpfParser keeps references to its path arguments, so they must outlive the parser. -const std::string kNoPath; -const std::string kMissingCache = "/missing-cache"; -const std::string kOpsBase = "OPS/"; +// The parser retains references to both paths, so they must outlive each instance. +const std::string kEmptyPath; +const std::string kMissingCachePath = "/missing-cache"; +const std::string kContentPath = "OPS/"; void parse(ContentOpfParser& parser, const std::string& xml) { ASSERT_TRUE(parser.setup()); @@ -20,8 +21,8 @@ void parse(ContentOpfParser& parser, const std::string& xml) { TEST(ContentOpfParserMetadata, EntityCallbackDoesNotSplitOneAuthor) { const std::string xml = - R"(Émile Zola)"; - ContentOpfParser parser(kNoPath, kNoPath, xml.size(), nullptr); + R"(Émile Zola)"; + ContentOpfParser parser(kEmptyPath, kEmptyPath, xml.size(), nullptr); parse(parser, xml); @@ -30,9 +31,9 @@ TEST(ContentOpfParserMetadata, EntityCallbackDoesNotSplitOneAuthor) { TEST(ContentOpfParserMetadata, ClampsOversizedMetadataTextInsteadOfGrowingUnbounded) { const std::string hugeTitle(64 * 1024, 'A'); - const std::string xml = - "" + hugeTitle + " tail"; - ContentOpfParser parser(kNoPath, kNoPath, xml.size(), nullptr); + const std::string xml = "" + hugeTitle + + " tail"; + ContentOpfParser parser(kEmptyPath, kEmptyPath, xml.size(), nullptr); parse(parser, xml); @@ -42,9 +43,9 @@ TEST(ContentOpfParserMetadata, ClampsOversizedMetadataTextInsteadOfGrowingUnboun TEST(ContentOpfParserMetadata, ClampDoesNotSplitUtf8Codepoints) { const std::string prefix(511, 'A'); - const std::string xml = "" + prefix + + const std::string xml = "" + prefix + "\xC3\xA9&tail"; - ContentOpfParser parser(kNoPath, kNoPath, xml.size(), nullptr); + ContentOpfParser parser(kEmptyPath, kEmptyPath, xml.size(), nullptr); parse(parser, xml); @@ -56,9 +57,9 @@ TEST(ContentOpfParserMetadata, ClampNeverOvershootsAtAMultiCreatorSeparatorBound // second creator's leading ", " separator is the thing that would push the // total past 512 if the clamp checked only the next character. const std::string firstAuthor(511, 'A'); - const std::string xml = "" + firstAuthor + - "B"; - ContentOpfParser parser(kNoPath, kNoPath, xml.size(), nullptr); + const std::string xml = "" + + firstAuthor + "B"; + ContentOpfParser parser(kEmptyPath, kEmptyPath, xml.size(), nullptr); parse(parser, xml); @@ -67,7 +68,7 @@ TEST(ContentOpfParserMetadata, ClampNeverOvershootsAtAMultiCreatorSeparatorBound } TEST(ContentOpfParserMetadata, SeparatesCreatorElementsAndCollapsesXmlWhitespace) { - const std::string xml = R"( + const std::string xml = R"( The Left Hand of Darkness Ursula K. Le Guin @@ -75,7 +76,7 @@ TEST(ContentOpfParserMetadata, SeparatesCreatorElementsAndCollapsesXmlWhitespace Octavia E. Butler )"; - ContentOpfParser parser(kNoPath, kNoPath, xml.size(), nullptr); + ContentOpfParser parser(kEmptyPath, kEmptyPath, xml.size(), nullptr); parse(parser, xml); @@ -84,13 +85,13 @@ Octavia E. Butler } TEST(ContentOpfParserMetadata, ReadsCalibreSeriesAndFirstSubject) { - const std::string xml = R"( + const std::string xml = R"( Fantasy & Adventure Young adult )"; - ContentOpfParser parser(kNoPath, kNoPath, xml.size(), nullptr); + ContentOpfParser parser(kEmptyPath, kEmptyPath, xml.size(), nullptr); parse(parser, xml); EXPECT_EQ(parser.series, "Earthsea"); EXPECT_EQ(parser.seriesIndex, "2.5"); @@ -103,7 +104,7 @@ TEST(ContentOpfParserMetadata, DistinguishesEpubThreeSeriesFromSets) { series 3 )"; - ContentOpfParser parser(kNoPath, kNoPath, xml.size(), nullptr); + ContentOpfParser parser(kEmptyPath, kEmptyPath, xml.size(), nullptr); parse(parser, xml); EXPECT_EQ(parser.series, "Earthsea"); EXPECT_EQ(parser.seriesIndex, "3"); @@ -113,7 +114,7 @@ TEST(ContentOpfParserMetadata, DistinguishesEpubThreeSeriesFromSets) { set 1 )"; - ContentOpfParser setParser(kNoPath, kNoPath, setXml.size(), nullptr); + ContentOpfParser setParser(kEmptyPath, kEmptyPath, setXml.size(), nullptr); parse(setParser, setXml); EXPECT_TRUE(setParser.series.empty()); EXPECT_TRUE(setParser.seriesIndex.empty()); @@ -125,21 +126,21 @@ TEST(ContentOpfParserMetadata, DistinguishesEpubThreeSeriesFromSets) { series 2 )"; - ContentOpfParser mixedParser(kNoPath, kNoPath, mixedXml.size(), nullptr); + ContentOpfParser mixedParser(kEmptyPath, kEmptyPath, mixedXml.size(), nullptr); parse(mixedParser, mixedXml); EXPECT_EQ(mixedParser.series, "Earthsea"); EXPECT_EQ(mixedParser.seriesIndex, "2"); } TEST(ContentOpfParserMetadata, StopsBeforeManifestWithoutOpeningTemporaryStorage) { - const std::string xml = R"( + const std::string xml = R"( A Wizard of Earthsea Ursula K. Le Guin en )"; Storage = {}; - ContentOpfParser parser(kMissingCache, kOpsBase, xml.size(), nullptr, /*collectCssFiles=*/true, + ContentOpfParser parser(kMissingCachePath, kContentPath, xml.size(), nullptr, /*collectCssFiles=*/true, /*metadataOnly=*/true); ASSERT_TRUE(parser.setup()); @@ -155,7 +156,7 @@ TEST(ContentOpfParserMetadata, NeverEntersManifestWhenMetadataElementIsMissing) const std::string xml = R"()"; Storage = {}; - ContentOpfParser parser(kMissingCache, kOpsBase, xml.size(), nullptr, /*collectCssFiles=*/true, + ContentOpfParser parser(kMissingCachePath, kContentPath, xml.size(), nullptr, /*collectCssFiles=*/true, /*metadataOnly=*/true); ASSERT_TRUE(parser.setup()); @@ -163,3 +164,78 @@ TEST(ContentOpfParserMetadata, NeverEntersManifestWhenMetadataElementIsMissing) EXPECT_EQ(Storage.writeOpens, 0); EXPECT_EQ(Storage.readOpens, 0); } + +TEST(ContentOpfParserNamespaces, RecognizesOptimizerPrefixesAndAlternateDublinCorePrefix) { + const std::string xml = R"( + SampleAuthor + enFiction + + + + + + + + + )"; + Storage = {}; + const std::string cachePath = "/cache"; + const std::string basePath = "OPS/"; + BookMetadataCache cache; + ContentOpfParser parser(cachePath, basePath, xml.size(), &cache); + parse(parser, xml); + ASSERT_EQ(cache.spine.size(), 1u); + EXPECT_EQ(cache.spine[0], "OPS/chapter.xhtml"); + EXPECT_EQ(parser.title, "Sample"); + EXPECT_EQ(parser.author, "Author"); + EXPECT_EQ(parser.language, "en"); + EXPECT_EQ(parser.subject, "Fiction"); + EXPECT_EQ(parser.tocNcxPath, "OPS/toc.ncx"); + ASSERT_EQ(parser.cssFiles.size(), 1u); + EXPECT_EQ(parser.cssFiles[0], "OPS/style.css"); + EXPECT_EQ(parser.textReferenceHref, "OPS/chapter.xhtml"); + EXPECT_EQ(Storage.writeOpens, 1); + EXPECT_EQ(Storage.readOpens, 2); +} + +TEST(ContentOpfParserNamespaces, MetadataOnlyStopsBeforePrefixedManifest) { + const std::string xml = R"( + Sample + + )"; + Storage = {}; + ContentOpfParser parser(kEmptyPath, kEmptyPath, xml.size(), nullptr, true, true); + ASSERT_TRUE(parser.setup()); + EXPECT_LT(parser.write(reinterpret_cast(xml.data()), xml.size()), xml.size()); + EXPECT_EQ(parser.title, "Sample"); + EXPECT_EQ(Storage.writeOpens, 0); + EXPECT_EQ(Storage.readOpens, 0); +} + +TEST(ContentOpfParserNamespaces, MetadataOnlySkipsPrefixedManifestWithoutMetadata) { + const std::string xml = R"( + + )"; + Storage = {}; + ContentOpfParser parser(kEmptyPath, kEmptyPath, xml.size(), nullptr, true, true); + ASSERT_TRUE(parser.setup()); + EXPECT_LT(parser.write(reinterpret_cast(xml.data()), xml.size()), xml.size()); + EXPECT_EQ(Storage.writeOpens, 0); + EXPECT_EQ(Storage.readOpens, 0); +} + +TEST(ContentOpfParserNamespaces, IgnoresUnrelatedNamespacesWithMatchingLocalNames) { + const std::string xml = R"( + Wrong titleRight title + + + )"; + ContentOpfParser parser(kEmptyPath, kEmptyPath, xml.size(), nullptr); + parse(parser, xml); + EXPECT_EQ(parser.title, "Right title"); + ASSERT_EQ(parser.cssFiles.size(), 1u); + EXPECT_EQ(parser.cssFiles[0], "right.css"); +} diff --git a/test/content_opf_parser/stubs/Epub.h b/test/content_opf_parser/stubs/Epub.h index 312cb7354af..f5be4359b58 100644 --- a/test/content_opf_parser/stubs/Epub.h +++ b/test/content_opf_parser/stubs/Epub.h @@ -1,15 +1,13 @@ #pragma once +#include #include #include +#include #include +#include -// Minimal stand-in for the HalFile surface ContentOpfParser actually calls: -// the manifest-index temp-file read/write path and the RAII bool check in its -// destructor. Real seek/read semantics are irrelevant here because every test -// in this suite either passes cache=nullptr (manifest indexing never runs) or -// exercises the metadataOnly early-stop, which returns before the manifest is -// ever opened. +// In-memory manifest file shared across write/read opens for spine lookup tests. class HalFile { public: explicit operator bool() const { return open_; } @@ -23,12 +21,30 @@ class HalFile { return true; } size_t position() const { return position_; } - size_t size() const { return 0; } - int available() const { return 0; } - int read(void*, size_t) { return 0; } - void markOpen() { open_ = true; } + size_t size() const { return bytes_ ? bytes_->size() : 0; } + int available() const { return static_cast(size() - position_); } + int read(void* out, size_t length) { + if (!open_ || position_ > size()) return 0; + const size_t count = std::min(length, size() - position_); + if (count) std::memcpy(out, bytes_->data() + position_, count); + position_ += count; + return static_cast(count); + } + size_t write(const void* data, size_t length) { + if (!open_) return 0; + bytes_->resize(std::max(bytes_->size(), position_ + length)); + std::memcpy(bytes_->data() + position_, data, length); + position_ += length; + return length; + } + void markOpen(std::vector& bytes) { + bytes_ = &bytes; + position_ = 0; + open_ = true; + } private: + std::vector* bytes_ = nullptr; bool open_ = false; size_t position_ = 0; }; @@ -36,15 +52,17 @@ class HalFile { struct TestStorage { int writeOpens = 0; int readOpens = 0; + std::vector bytes; bool openFileForWrite(const char*, const std::string&, HalFile& file) { writeOpens++; - file.markOpen(); + bytes.clear(); + file.markOpen(bytes); return true; } bool openFileForRead(const char*, const std::string&, HalFile& file) { readOpens++; - file.markOpen(); + file.markOpen(bytes); return true; } bool exists(const char*) const { return false; } diff --git a/test/content_opf_parser/stubs/Epub/BookMetadataCache.h b/test/content_opf_parser/stubs/Epub/BookMetadataCache.h index e0c53acab38..77cc8e671ef 100644 --- a/test/content_opf_parser/stubs/Epub/BookMetadataCache.h +++ b/test/content_opf_parser/stubs/Epub/BookMetadataCache.h @@ -1,8 +1,10 @@ #pragma once #include +#include class BookMetadataCache { public: - void createSpineEntry(const std::string&) {} + std::vector spine; + void createSpineEntry(const std::string& href) { spine.push_back(href); } }; diff --git a/test/content_opf_parser/stubs/Serialization.h b/test/content_opf_parser/stubs/Serialization.h index 4cf92239be2..42c4c1b893b 100644 --- a/test/content_opf_parser/stubs/Serialization.h +++ b/test/content_opf_parser/stubs/Serialization.h @@ -7,17 +7,24 @@ namespace serialization { template -void writePod(HalFile&, const T&) {} +void writePod(HalFile& file, const T& value) { + file.write(&value, sizeof(value)); +} template -bool tryReadPod(HalFile&, T&) { - return false; +bool tryReadPod(HalFile& file, T& value) { + return file.read(&value, sizeof(value)) == sizeof(value); } -inline void writeString(HalFile&, const std::string&) {} -inline bool tryReadString(HalFile&, std::string& out) { - out.clear(); - return false; +inline void writeString(HalFile& file, const std::string& value) { + writePod(file, static_cast(value.size())); + file.write(value.data(), value.size()); +} +inline bool tryReadString(HalFile& file, std::string& out) { + uint32_t length = 0; + if (!tryReadPod(file, length) || length > static_cast(file.available())) return false; + out.resize(length); + return file.read(out.data(), length) == static_cast(length); } } // namespace serialization diff --git a/test/differential_rounding/DifferentialRoundingTest.cpp b/test/differential_rounding/DifferentialRoundingTest.cpp index 358a3b3baca..d2847300afb 100644 --- a/test/differential_rounding/DifferentialRoundingTest.cpp +++ b/test/differential_rounding/DifferentialRoundingTest.cpp @@ -472,6 +472,39 @@ void testFamilySpaceGlyphsStayBlank() { PASS(); } +void testFamilyInkBounds() { + printf("testFamilyInkBounds...\n"); + static const EpdGlyph glyphs[] = { + {0, 0, 64, 0, 0, 0, 0}, // space + {7, 6, 130, 0, 8, 0, 0}, // a: ink ends above the baseline + {7, 11, 130, 0, 8, 0, 0}, // g: ink extends below the baseline + {3, 3, 0, 0, 10, 0, 0}, // combining acute accent + }; + static const EpdUnicodeInterval intervals[] = { + {0x20, 0x20, 0}, {0x61, 0x61, 1}, {0x67, 0x67, 2}, {0x0301, 0x0301, 3}}; + EpdFontData data = kTestFontData; + data.glyph = glyphs; + data.intervals = intervals; + data.intervalCount = 4; + EpdFont font(&data); + EpdFontFamily family(&font); + int width = 0, height = 0, minY = 0, maxY = 0; + + family.getTextDimensions(" a ", &width, &height, EpdFontFamily::REGULAR, &minY, &maxY); + ASSERT_EQ(minY, 2); // Blank spaces must not extend the ink to the baseline. + ASSERT_EQ(maxY, 8); + family.getTextDimensions("ag", &width, &height, EpdFontFamily::REGULAR, &minY, &maxY); + ASSERT_EQ(minY, -3); + ASSERT_EQ(maxY, 8); + family.getTextDimensions("a\xcc\x81", &width, &height, EpdFontFamily::REGULAR, &minY, &maxY); + ASSERT_EQ(minY, 2); + ASSERT_EQ(maxY, 12); // The raised accent sits one pixel above the base letter. + family.getTextDimensions("", &width, &height, EpdFontFamily::REGULAR, &minY, &maxY); + ASSERT_EQ(minY, 0); + ASSERT_EQ(maxY, 0); + PASS(); +} + void testHeightCalculation() { printf("testHeightCalculation...\n"); @@ -505,6 +538,7 @@ int main() { testNullGlyphAdvancePreserved(); testFamilyMissingGlyphUsesReplacementFallback(); testFamilySpaceGlyphsStayBlank(); + testFamilyInkBounds(); testHeightCalculation(); printf("\n=== Results: %d passed, %d failed ===\n", testsPassed, testsFailed); diff --git a/test/epub_grayscale/CMakeLists.txt b/test/epub_grayscale/CMakeLists.txt index 0c1417bf4d9..b7555ba1b07 100644 --- a/test/epub_grayscale/CMakeLists.txt +++ b/test/epub_grayscale/CMakeLists.txt @@ -26,12 +26,14 @@ add_executable(EpubTextGrayscaleTest EpubTextGrayscaleTest.cpp ${REPO_ROOT}/lib/uzlib/src/tinflate.c ${REPO_ROOT}/lib/uzlib/src/uzlib_checksums.c) target_include_directories(EpubTextGrayscaleTest PRIVATE raster_stubs + ${REPO_ROOT}/include ${REPO_ROOT}/src/activities/reader ${REPO_ROOT}/freeink-sdk/libs/display/FreeInkDisplay/include ${REPO_ROOT}/lib/GfxRenderer ../memory_policy/stubs ${REPO_ROOT}/lib/Epub ${REPO_ROOT}/lib/EpdFont ${REPO_ROOT}/lib/Memory ${REPO_ROOT}/lib/Serialization ${REPO_ROOT}/lib/Utf8 ${REPO_ROOT}/lib/MiniBidi ${REPO_ROOT}/lib/miniz/src ${REPO_ROOT}/lib/InflateReader ${REPO_ROOT}/lib/uzlib/src) -target_compile_definitions(EpubTextGrayscaleTest PRIVATE ARDUINO_ARCH_ESP32=1 CROSSINK_MEMORY_TEST=1) +target_compile_definitions(EpubTextGrayscaleTest PRIVATE ARDUINO_ARCH_ESP32=1 CROSSINK_MEMORY_TEST=1 + CROSSINK_APP_CAP_TOUCH=0 CROSSINK_APP_CAP_USB_DRIVE=0) target_link_libraries(EpubTextGrayscaleTest PRIVATE crosspoint_test_common GTest::gtest_main) gtest_discover_tests(EpubTextGrayscaleTest PROPERTIES ENVIRONMENT "UBSAN_OPTIONS=halt_on_error=1") diff --git a/test/epub_grayscale/EpubTextGrayscaleTest.cpp b/test/epub_grayscale/EpubTextGrayscaleTest.cpp index b2aa2680d86..1e75dc815c9 100644 --- a/test/epub_grayscale/EpubTextGrayscaleTest.cpp +++ b/test/epub_grayscale/EpubTextGrayscaleTest.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -17,6 +18,7 @@ struct RasterFont { EpdFont font{&data}; explicit RasterFont(int size) { for (auto [first, last] : {std::pair{32u, 127u}, + {0xB7u, 0xB7u}, {0x300u, 0x36Fu}, {0x590u, 0x6FFu}, {0x4E00u, 0x4E20u}, @@ -271,3 +273,50 @@ TEST(AbsoluteImageRaster, BitmapPlanesPreserveFourTonesAndWhiteMargins) { EXPECT_EQ(display.canceled, 1); file.close(); } + +// A font switch begins with no resident glyphs. A wide replacement glyph makes +// the cold scan fit fewer words than the final, correctly measured preview. +TEST(EpubTextGrayscaleTest, ColdSdSamplePreviewMatchesFullyLoadedFont) { + for (int size : {12, 20}) { + for (int width : {160, 280}) { + for (bool focus : {false, true}) { + for (bool guide : {false, true}) { + SCOPED_TRACE(testing::Message() << size << " width=" << width << " focus=" << focus << " guide=" << guide); + fakeheap::reset(false); + Storage.reset(); + RasterFont fixture(size); + fixture.glyphs.back().advanceX = size * 16 * 4; + SdCardFont sdFont; + Storage.put("preview.cpfont", fixture.file()); + ASSERT_TRUE(sdFont.load("preview.cpfont")); + HalDisplay display; + GfxRenderer renderer(display); + renderer.begin(); + renderer.insertFont(1, EpdFontFamily(sdFont.getEpdFont())); + renderer.registerSdCardFont(1, &sdFont); + renderer.insertFont(2, EpdFontFamily(&fixture.font)); + FontCacheManager cache(renderer.getFontMap(), renderer.getSdCardFonts()); + renderer.setFontCacheManager(&cache); + SampleReaderPreviewModel model; + ASSERT_TRUE(model.captureParagraph(READER_PREVIEW_PARAGRAPH)); + auto draw = [&](int font) { + model.renderText(renderer, font, 10, 10, width, 100, 0, static_cast(CssTextAlign::Left), focus, + guide, true, 85); + }; + renderer.clearScreen(); + draw(2); + const auto expected = display.bw; + renderer.clearScreen(); + const auto blank = display.bw; + auto scope = cache.createPrewarmScope(); + draw(1); + EXPECT_EQ(display.bw, blank); // Scanning must not paint the display. + ASSERT_TRUE(scope.endScanAndPrewarm()); + draw(1); + EXPECT_NE(display.bw, blank); + EXPECT_TRUE(display.bw == expected); + } + } + } + } +} diff --git a/test/epub_grayscale/raster_stubs/BoardConfig.h b/test/epub_grayscale/raster_stubs/BoardConfig.h new file mode 100644 index 00000000000..88197033c8b --- /dev/null +++ b/test/epub_grayscale/raster_stubs/BoardConfig.h @@ -0,0 +1,4 @@ +#pragma once + +#define FREEINK_CAP_TOUCH 0 +#define FREEINK_CAP_USB_MSC 0 diff --git a/test/koreader_xpath_resolver/KOReaderXPathResolverTest.cpp b/test/koreader_xpath_resolver/KOReaderXPathResolverTest.cpp index 46f0720b83b..84e055437ec 100644 --- a/test/koreader_xpath_resolver/KOReaderXPathResolverTest.cpp +++ b/test/koreader_xpath_resolver/KOReaderXPathResolverTest.cpp @@ -99,3 +99,58 @@ TEST(KOReaderXPathResolver, KeepsParagraphOnlyResolutionUnchanged) { EXPECT_EQ(ChapterXPathResolver::findXPathForParagraph(epubWith(kNestedFixture), 0, 2), "/body/DocFragment[1]/body/div[1]/section[1]/p[2]"); } + +namespace { +// Inky keeps formatting whitespace around a split chapter's mapped children. +constexpr char kSplitFixture[] = + "\n\n
\n\n

Alpha

\n\n

Bravo

\n\n
\n"; +} // namespace + +TEST(KOReaderXPathResolver, SplitStartSkipsBodyAndContainerWhitespace) { + const auto epub = epubWith(kSplitFixture); + for (uint32_t offset = 0; offset <= 6; ++offset) { + EXPECT_EQ(ChapterXPathResolver::findXPathForVisibleTextOffset(epub, 0, offset, 2), + "/body/DocFragment[1]/body/section[1]/p[1]/text()[1].0") + << offset; + } + EXPECT_EQ(ChapterXPathResolver::findXPathForProgress(epub, 0, 0.0f, 2), + "/body/DocFragment[1]/body/section[1]/p[1]/text()[1].0"); +} + +TEST(KOReaderXPathResolver, SplitInteriorRetainsExactOffset) { + EXPECT_EQ(ChapterXPathResolver::findXPathForVisibleTextOffset(epubWith(kSplitFixture), 0, 8, 2), + "/body/DocFragment[1]/body/section[1]/p[1]/text()[1].2"); +} + +TEST(KOReaderXPathResolver, SplitGapAdvancesToNextChild) { + const auto epub = epubWith(kSplitFixture); + for (uint32_t offset = 11; offset <= 14; ++offset) { + EXPECT_EQ(ChapterXPathResolver::findXPathForVisibleTextOffset(epub, 0, offset, 2), + "/body/DocFragment[1]/body/section[1]/p[2]/text()[1].0") + << offset; + } +} + +TEST(KOReaderXPathResolver, SplitEndUsesLastContentCharacter) { + const auto epub = epubWith(kSplitFixture); + for (uint32_t offset = 19; offset <= 23; ++offset) { + EXPECT_EQ(ChapterXPathResolver::findXPathForVisibleTextOffset(epub, 0, offset, 2), + "/body/DocFragment[1]/body/section[1]/p[2]/text()[1].4") + << offset; + } + EXPECT_EQ(ChapterXPathResolver::findXPathForProgress(epub, 0, 1.0f, 2), + "/body/DocFragment[1]/body/section[1]/p[2]/text()[1].4"); + EXPECT_TRUE(ChapterXPathResolver::findXPathForVisibleTextOffset(epub, 0, 100, 2).empty()); +} + +TEST(KOReaderXPathResolver, SplitWithoutMappedContentDoesNotInventAnchor) { + EXPECT_TRUE(ChapterXPathResolver::findXPathForVisibleTextOffset( + epubWith("\n
\n
"), 0, 0, 2) + .empty()); +} + +TEST(KOReaderXPathResolver, SplitDirectChildrenAndNestedInlineText) { + EXPECT_EQ(ChapterXPathResolver::findXPathForVisibleTextOffset( + epubWith("\n

Hello

"), 0, 0, 1), + "/body/DocFragment[1]/body/p[1]/em[1]/text()[1].0"); +} diff --git a/test/library_builder/LibraryBuilderTest.cpp b/test/library_builder/LibraryBuilderTest.cpp index dad933b309c..c923498dfbc 100644 --- a/test/library_builder/LibraryBuilderTest.cpp +++ b/test/library_builder/LibraryBuilderTest.cpp @@ -1180,3 +1180,12 @@ TEST_F(LibraryBuilderTest, EmptyLibraryStillRecordsMetadataModeChanges) { ASSERT_TRUE(index.open(INDEX)); EXPECT_EQ(index.header().metadataEnabled, 1); } + +TEST_F(LibraryBuilderTest, SleepRestorationStillAllowsFileChangesToInvalidate) { + restoreLibraryIndexAfterSleep(); + EXPECT_FALSE(libraryIndexNeedsRefresh()); + invalidateLibraryIndex(); + EXPECT_TRUE(libraryIndexNeedsRefresh()); + initial(); + EXPECT_FALSE(libraryIndexNeedsRefresh()); +} diff --git a/test/library_input_buffer/CMakeLists.txt b/test/library_input_buffer/CMakeLists.txt new file mode 100644 index 00000000000..14a58157a4d --- /dev/null +++ b/test/library_input_buffer/CMakeLists.txt @@ -0,0 +1,7 @@ +add_executable(LibraryInputBufferTest LibraryInputBufferTest.cpp) +target_include_directories(LibraryInputBufferTest PRIVATE + ${REPO_ROOT}/src/activities/library + ${REPO_ROOT}/lib/LibraryIndex +) +target_link_libraries(LibraryInputBufferTest PRIVATE crosspoint_test_common GTest::gtest_main) +gtest_discover_tests(LibraryInputBufferTest) diff --git a/test/library_input_buffer/LibraryInputBufferTest.cpp b/test/library_input_buffer/LibraryInputBufferTest.cpp new file mode 100644 index 00000000000..12d9a8e7aae --- /dev/null +++ b/test/library_input_buffer/LibraryInputBufferTest.cpp @@ -0,0 +1,79 @@ +#include + +#include "LibraryInputBuffer.h" +#include "LibraryScanSleepToken.h" + +using Type = LibraryInputBuffer::Type; + +TEST(LibraryInputBufferTest, NavigationStaysBeforeSelect) { + LibraryInputBuffer buffer; + ASSERT_TRUE(buffer.push({Type::Next})); + ASSERT_TRUE(buffer.push({Type::Previous})); + ASSERT_TRUE(buffer.push({Type::ConfirmRelease})); + LibraryInputBuffer::Event event; + ASSERT_TRUE(buffer.pop(event)); + EXPECT_EQ(event.type, Type::Next); + ASSERT_TRUE(buffer.pop(event)); + EXPECT_EQ(event.type, Type::Previous); + ASSERT_TRUE(buffer.pop(event)); + EXPECT_EQ(event.type, Type::ConfirmRelease); + EXPECT_FALSE(buffer.pop(event)); +} + +TEST(LibraryInputBufferTest, FullQueueAndWrappedReuseKeepTouchEdgesInOrder) { + LibraryInputBuffer buffer; + for (size_t i = 0; i < buffer.CAPACITY; ++i) + ASSERT_TRUE(buffer.push({Type::TouchPress, static_cast(i), 42})); + EXPECT_FALSE(buffer.push({Type::ConfirmRelease})); + LibraryInputBuffer::Event event; + for (int i = 0; i < 12; ++i) { + ASSERT_TRUE(buffer.pop(event)); + EXPECT_EQ(event.x, i); + } + for (int i = 0; i < 12; ++i) ASSERT_TRUE(buffer.push({Type::TouchRelease, -1, -1})); + for (size_t i = 12; i < buffer.CAPACITY; ++i) { + ASSERT_TRUE(buffer.pop(event)); + EXPECT_EQ(event.x, i); + EXPECT_EQ(event.y, 42); + } + for (int i = 0; i < 12; ++i) { + ASSERT_TRUE(buffer.pop(event)); + EXPECT_EQ(event.type, Type::TouchRelease); + EXPECT_EQ(event.x, -1); + } + EXPECT_FALSE(buffer.pop(event)); +} + +TEST(LibraryInputBufferTest, ClearingOnNavigationDropsOldScreenEvents) { + LibraryInputBuffer buffer; + ASSERT_TRUE(buffer.push({Type::ConfirmRelease})); + ASSERT_TRUE(buffer.push({Type::TouchRelease, 20, 300})); + buffer.clear(); + LibraryInputBuffer::Event event; + EXPECT_FALSE(buffer.pop(event)); + ASSERT_TRUE(buffer.push({Type::BackRelease})); + ASSERT_TRUE(buffer.pop(event)); + EXPECT_EQ(event.type, Type::BackRelease); +} + +TEST(LibraryScanSleepTokenTest, CleanScanSurvivesOnlyOneDeepSleepWake) { + library::ScanSleepToken token{}; + token.save(true); + EXPECT_TRUE(token.consume(true)); + EXPECT_FALSE(token.consume(true)); + token.save(true); + EXPECT_FALSE(token.consume(false)); + EXPECT_FALSE(token.consume(true)); +} + +TEST(LibraryScanSleepTokenTest, DirtyAndCorruptedScansDoNotSkipReconciliation) { + library::ScanSleepToken token{}; + token.save(false); + EXPECT_FALSE(token.consume(true)); + token.save(true); + token.check ^= 1; + EXPECT_FALSE(token.consume(true)); + token.save(true); + token.magic ^= 1; + EXPECT_FALSE(token.consume(true)); +} diff --git a/test/persistable_store_atomic/CMakeLists.txt b/test/persistable_store_atomic/CMakeLists.txt index 94b657c90f6..d2ae608be9f 100644 --- a/test/persistable_store_atomic/CMakeLists.txt +++ b/test/persistable_store_atomic/CMakeLists.txt @@ -21,6 +21,22 @@ target_link_libraries(PersistableStoreAtomicTest PRIVATE crosspoint_test_common ArduinoJson GTest::gtest_main + ArduinoJson ) gtest_discover_tests(PersistableStoreAtomicTest) + +add_executable(CrossPointStateTest + CrossPointStateTest.cpp + ${REPO_ROOT}/src/CrossPointState.cpp + ${REPO_ROOT}/lib/Serialization/PersistableStore.cpp + ${REPO_ROOT}/lib/uzlib/src/uzlib_checksums.c +) +target_include_directories(CrossPointStateTest PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/stubs + ${REPO_ROOT}/src + ${REPO_ROOT}/lib/Serialization + ${REPO_ROOT}/lib/uzlib/src +) +target_link_libraries(CrossPointStateTest PRIVATE crosspoint_test_common GTest::gtest_main ArduinoJson) +gtest_discover_tests(CrossPointStateTest) diff --git a/test/persistable_store_atomic/CrossPointStateTest.cpp b/test/persistable_store_atomic/CrossPointStateTest.cpp new file mode 100644 index 00000000000..ae032388b8f --- /dev/null +++ b/test/persistable_store_atomic/CrossPointStateTest.cpp @@ -0,0 +1,77 @@ +#include +#include +#include + +namespace { +constexpr char STATE_PATH[] = "/.crosspoint/state.json"; +} + +class CrossPointStateTest : public testing::Test { + protected: + void SetUp() override { + Storage.reset(); + EXPECT_FALSE(APP_STATE.loadFromFile()); // Clear the remembered on-disk snapshot. + JsonDocument defaults; + APP_STATE.fromJson(defaults.as()); + } +}; + +TEST_F(CrossPointStateTest, SkipsRepeatedSuccessfulSnapshot) { + APP_STATE.openEpubPath = "/book.epub"; + ASSERT_TRUE(APP_STATE.saveToFile()); + ASSERT_TRUE(APP_STATE.saveToFile()); + EXPECT_EQ(Storage.writeAttempts, 1U); +} + +TEST_F(CrossPointStateTest, PersistsReaderExitAndWallpaperChanges) { + APP_STATE.readerActivityLoadCount = 1; + ASSERT_TRUE(APP_STATE.saveToFile()); + APP_STATE.readerActivityLoadCount = 0; + ASSERT_TRUE(APP_STATE.saveToFile()); + APP_STATE.pushRecentSleep(9); + ASSERT_TRUE(APP_STATE.saveToFile()); + EXPECT_EQ(Storage.writeAttempts, 3U); + ASSERT_TRUE(APP_STATE.loadFromFile()); + EXPECT_EQ(APP_STATE.readerActivityLoadCount, 0); + EXPECT_TRUE(APP_STATE.isRecentSleep(9, 1)); +} + +TEST_F(CrossPointStateTest, RecreatesDeletedStateFile) { + ASSERT_TRUE(APP_STATE.saveToFile()); + ASSERT_TRUE(Storage.remove(STATE_PATH)); + ASSERT_TRUE(APP_STATE.saveToFile()); + EXPECT_TRUE(Storage.exists(STATE_PATH)); + EXPECT_EQ(Storage.writeAttempts, 2U); +} + +TEST_F(CrossPointStateTest, RetriesFailedFirstSave) { + Storage.failNextWrite(); + EXPECT_FALSE(APP_STATE.saveToFile()); + EXPECT_TRUE(APP_STATE.saveToFile()); + EXPECT_EQ(Storage.writeAttempts, 2U); +} + +TEST_F(CrossPointStateTest, FailedRewriteInvalidatesPreviousSnapshot) { + APP_STATE.openEpubPath = "/old.epub"; + ASSERT_TRUE(APP_STATE.saveToFile()); + APP_STATE.openEpubPath = "/new.epub"; + Storage.failNextWrite(); + EXPECT_FALSE(APP_STATE.saveToFile()); + APP_STATE.openEpubPath = "/old.epub"; + ASSERT_TRUE(APP_STATE.saveToFile()); + EXPECT_EQ(Storage.writeAttempts, 3U); + ASSERT_TRUE(APP_STATE.loadFromFile()); + EXPECT_EQ(APP_STATE.openEpubPath, "/old.epub"); +} + +TEST_F(CrossPointStateTest, ReloadInvalidatesPreviousSnapshot) { + APP_STATE.openEpubPath = "/old.epub"; + ASSERT_TRUE(APP_STATE.saveToFile()); + Storage.put(STATE_PATH, R"({"openEpubPath":"/new.epub"})"); + ASSERT_TRUE(APP_STATE.loadFromFile()); + APP_STATE.openEpubPath = "/old.epub"; + ASSERT_TRUE(APP_STATE.saveToFile()); + EXPECT_EQ(Storage.writeAttempts, 2U); + ASSERT_TRUE(APP_STATE.loadFromFile()); + EXPECT_EQ(APP_STATE.openEpubPath, "/old.epub"); +} diff --git a/test/persistable_store_atomic/stubs/HalStorage.h b/test/persistable_store_atomic/stubs/HalStorage.h index 2ede25e5e23..d545b3a3621 100644 --- a/test/persistable_store_atomic/stubs/HalStorage.h +++ b/test/persistable_store_atomic/stubs/HalStorage.h @@ -2,14 +2,40 @@ #include +#include +#include #include #include +class HalFile { + public: + explicit HalFile(String content = {}) : content(std::move(content)) {} + int read(void* output, size_t length) { + const size_t count = std::min(length, content.size() - position); + memcpy(output, content.data() + position, count); + position += count; + return static_cast(count); + } + size_t write(const uint8_t*, size_t length) { return length; } + void close() { + content.clear(); + position = 0; + } + + private: + String content; + size_t position = 0; +}; + +using FsFile = HalFile; + class HalStorage { public: void reset() { files.clear(); failRenameFrom.clear(); + writeAttempts = 0; + failWrite = false; } bool mkdir(const char*, bool = true) { return true; } @@ -29,6 +55,12 @@ class HalStorage { } bool writeFile(const char* path, const String& content) { + ++writeAttempts; + if (failWrite) { + failWrite = false; + files[path] = ""; // Simulate a failed write after truncation. + return false; + } files[path] = content.c_str(); return true; } @@ -40,10 +72,18 @@ class HalStorage { void put(const std::string& path, std::string content) { files[path] = std::move(content); } void failNextRenameFrom(std::string path) { failRenameFrom = std::move(path); } + void failNextWrite() { failWrite = true; } + bool openFileForRead(const char*, const char* path, HalFile& file) const { + if (!exists(path)) return false; + file = HalFile(readFile(path)); + return true; + } + size_t writeAttempts = 0; private: std::unordered_map files; std::string failRenameFrom; + bool failWrite = false; }; inline HalStorage Storage; diff --git a/test/persistable_store_atomic/stubs/Logging.h b/test/persistable_store_atomic/stubs/Logging.h index ad76e7501af..81586bbe744 100644 --- a/test/persistable_store_atomic/stubs/Logging.h +++ b/test/persistable_store_atomic/stubs/Logging.h @@ -2,3 +2,4 @@ #define LOG_ERR(...) #define LOG_INF(...) +#define LOG_DBG(...) diff --git a/test/section_persistence/CMakeLists.txt b/test/section_persistence/CMakeLists.txt index 0d26988092f..bf659cf1036 100644 --- a/test/section_persistence/CMakeLists.txt +++ b/test/section_persistence/CMakeLists.txt @@ -2,6 +2,7 @@ add_executable(SectionPersistenceTest SectionPersistenceTest.cpp SectionPersistenceLinkStubs.cpp ${REPO_ROOT}/lib/Epub/Epub/Section.cpp + ${REPO_ROOT}/lib/Epub/Epub/css/CssParser.cpp ) target_include_directories(SectionPersistenceTest PRIVATE diff --git a/test/section_persistence/SectionPersistenceLinkStubs.cpp b/test/section_persistence/SectionPersistenceLinkStubs.cpp index 040f28a71a5..5df5ec3b762 100644 --- a/test/section_persistence/SectionPersistenceLinkStubs.cpp +++ b/test/section_persistence/SectionPersistenceLinkStubs.cpp @@ -19,10 +19,6 @@ int Epub::getTocItemsCount() const { return 0; } int Epub::getTocIndexForSpineIndex(int) const { return -1; } bool Epub::readItemContentsToStream(const std::string&, Print&, size_t, bool) const { return false; } -bool CssParser::loadFromCache() { return false; } -size_t CssParser::SvHash::operator()(std::string_view sv) const noexcept { return std::hash{}(sv); } -size_t CssParser::SvHash::operator()(const std::string& s) const noexcept { return operator()(std::string_view(s)); } - void Hyphenator::setPreferredLanguage(const std::string&) {} ChapterHtmlSlimParser::~ChapterHtmlSlimParser() = default; @@ -43,4 +39,4 @@ std::unique_ptr Page::deserialize(FsFile& file) { return std::make_unique(); } -uint16_t Page::imageEstimateUnits(uint16_t) const { return 0; } +uint16_t Page::imageEstimateUnits(uint16_t, uint16_t) const { return 0; } diff --git a/test/section_persistence/SectionPersistenceTest.cpp b/test/section_persistence/SectionPersistenceTest.cpp index eaa1cf885c0..90860bfe840 100644 --- a/test/section_persistence/SectionPersistenceTest.cpp +++ b/test/section_persistence/SectionPersistenceTest.cpp @@ -1,5 +1,7 @@ #include +// Include standard headers before the class/private macros below: libstdc++ +// templates declared with `class` do not compile if first parsed under them. #include #include #include @@ -19,10 +21,17 @@ #include namespace { -constexpr uint8_t kFullVersion = 79; -constexpr uint8_t kPartialVersion = 0xF4; -constexpr uint8_t kPreviousFullVersion = 78; -constexpr uint8_t kPreviousPartialVersion = 0xF2; +constexpr uint8_t kFullVersion = 81; +constexpr uint8_t kPartialVersion = 0xC2; +constexpr uint8_t kPreviousFullVersion = 80; +constexpr uint8_t kPreviousPartialVersion = 0xC1; +constexpr uint8_t kOlderFullVersion = 79; +constexpr uint8_t kOlderPartialVersion = 0xF4; +constexpr uint8_t kEarlierFullVersion = 78; +constexpr uint8_t kEarlierPartialVersion = 0xF2; +constexpr uint8_t kLastReleaseFullVersion = 77; +constexpr uint8_t kLastReleasePartialVersion = 0xF3; +constexpr uint8_t kPreviousReleasePrepPartialVersion = 0x80; ReaderRenderSpec renderSpec() { ReaderRenderSpec spec; @@ -155,7 +164,10 @@ TEST_F(SectionPersistenceTest, FailedCommitKeepsThePreviousReadableCache) { } TEST_F(SectionPersistenceTest, RejectsCachesFromPreviousLayoutRevisions) { - for (const uint8_t staleVersion : {kPreviousFullVersion, kPreviousPartialVersion}) { + for (const uint8_t staleVersion : + {kPreviousFullVersion, kPreviousPartialVersion, kOlderFullVersion, kOlderPartialVersion, kEarlierFullVersion, + kEarlierPartialVersion, kLastReleaseFullVersion, kLastReleasePartialVersion, + kPreviousReleasePrepPartialVersion}) { SectionHarness harness; harness.begin(); harness.appendPages(1); diff --git a/test/touch_reader_preview/TouchReaderPreviewModelTest.cpp b/test/touch_reader_preview/TouchReaderPreviewModelTest.cpp index ac41eedcb6b..0fd05b09551 100644 --- a/test/touch_reader_preview/TouchReaderPreviewModelTest.cpp +++ b/test/touch_reader_preview/TouchReaderPreviewModelTest.cpp @@ -1,5 +1,6 @@ #include +#include #include #include "TouchReaderPreviewModel.h" diff --git a/test/touch_reader_preview/stubs/GfxRenderer.h b/test/touch_reader_preview/stubs/GfxRenderer.h index f7de3975b03..97c2028e0f5 100644 --- a/test/touch_reader_preview/stubs/GfxRenderer.h +++ b/test/touch_reader_preview/stubs/GfxRenderer.h @@ -17,6 +17,8 @@ class GfxRenderer { mutable std::vector drawCalls; + bool isFontCacheScanning() const { return false; } + int getLineHeight(int) const { return 10; } int getTextHeight(int) const { return 10; } int getSpaceWidth(int, EpdFontFamily::Style) const { return 1; }