From 8ad98e190938ad26845de31775225329816495d5 Mon Sep 17 00:00:00 2001 From: Pavl Zubenko Date: Wed, 22 Apr 2026 20:50:18 +0300 Subject: [PATCH 01/21] feat: add clipping functionality to the reader - Introduced `ClipSelectionActivity` for selecting and saving text clips. - Added `ClippingsManager` to handle saving clips to a file. - Updated `EpubReaderActivity` to initiate clipping and save selected text. - Enhanced `EpubReaderMenuActivity` to include an option for saving clips. - Added new translations for "Save Clipping" in multiple languages. - Modified `TextBlock` to expose word positions and styles for clipping. - Implemented windowed display updates in `GfxRenderer` and `HalDisplay`. --- lib/Epub/Epub/blocks/TextBlock.h | 2 + lib/GfxRenderer/GfxRenderer.cpp | 4 + lib/GfxRenderer/GfxRenderer.h | 4 +- lib/I18n/translations/belarusian.yaml | 1 + lib/I18n/translations/catalan.yaml | 1 + lib/I18n/translations/czech.yaml | 1 + lib/I18n/translations/danish.yaml | 1 + lib/I18n/translations/dutch.yaml | 1 + lib/I18n/translations/english.yaml | 1 + lib/I18n/translations/finnish.yaml | 1 + lib/I18n/translations/french.yaml | 1 + lib/I18n/translations/german.yaml | 1 + lib/I18n/translations/hungarian.yaml | 1 + lib/I18n/translations/italian.yaml | 1 + lib/I18n/translations/kazakh.yaml | 1 + lib/I18n/translations/lithuanian.yaml | 1 + lib/I18n/translations/polish.yaml | 1 + lib/I18n/translations/portuguese.yaml | 1 + lib/I18n/translations/romanian.yaml | 1 + lib/I18n/translations/russian.yaml | 1 + lib/I18n/translations/slovenian.yaml | 1 + lib/I18n/translations/spanish.yaml | 1 + lib/I18n/translations/swedish.yaml | 1 + lib/I18n/translations/turkish.yaml | 1 + lib/I18n/translations/ukrainian.yaml | 1 + lib/hal/HalDisplay.cpp | 5 + lib/hal/HalDisplay.h | 1 + src/activities/ActivityResult.h | 6 +- .../reader/ClipSelectionActivity.cpp | 239 ++++++++++++++++++ src/activities/reader/ClipSelectionActivity.h | 63 +++++ src/activities/reader/EpubReaderActivity.cpp | 73 ++++++ .../reader/EpubReaderMenuActivity.cpp | 3 +- .../reader/EpubReaderMenuActivity.h | 3 +- src/clippings/ClippingsManager.cpp | 47 ++++ src/clippings/ClippingsManager.h | 14 + 35 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 src/activities/reader/ClipSelectionActivity.cpp create mode 100644 src/activities/reader/ClipSelectionActivity.h create mode 100644 src/clippings/ClippingsManager.cpp create mode 100644 src/clippings/ClippingsManager.h diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index 85fdd55a39..0da365ce09 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -28,6 +28,8 @@ class TextBlock final : public Block { void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } const BlockStyle& getBlockStyle() const { return blockStyle; } const std::vector& getWords() const { return words; } + const std::vector& getWordXpos() const { return wordXpos; } + const std::vector& getWordStyles() const { return wordStyles; } bool isEmpty() override { return words.empty(); } size_t wordCount() const { return words.size(); } // given a renderer works out where to break the words into lines diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index a343badccd..5ef700ed1b 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -897,6 +897,10 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const display.displayBuffer(refreshMode, fadingFix); } +void GfxRenderer::displayWindow(int x, int y, int width, int height) const { + display.displayWindow(x, y, width, height); +} + std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, const EpdFontFamily::Style style) const { if (!text || maxWidth <= 0) return ""; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e683e3122e..b013f8a1b0 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -84,8 +84,8 @@ class GfxRenderer { int getScreenWidth() const; int getScreenHeight() const; void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; - // EXPERIMENTAL: Windowed update - display only a rectangular region - // void displayWindow(int x, int y, int width, int height) const; + // EXPERIMENTAL: Windowed update - display only a rectangular region (x and w must be multiples of 8) + void displayWindow(int x, int y, int width, int height) const; void invertScreen() const; void clearScreen(uint8_t color = 0xFF) const; void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const; diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index 1278c24f54..a42e206b1e 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -230,6 +230,7 @@ STR_GO_TO_PERCENT: "Перайсці да %" STR_GO_HOME_BUTTON: "На галоўную" STR_SYNC_PROGRESS: "Сінхранізаваць прагрэс" STR_DELETE_CACHE: "Выдаліць кэш кнігі" +STR_SAVE_CLIPPING: "Захаваць урывак" STR_CHAPTER_PREFIX: "Раздзел:" STR_PAGES_SEPARATOR: "стар. |" STR_BOOK_PREFIX: "Кніга:" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index d44783137f..afc2cbb117 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -253,6 +253,7 @@ STR_GO_TO_PERCENT: "Ves al %" STR_GO_HOME_BUTTON: "Ves a l'inici" STR_SYNC_PROGRESS: "Sincronitza el progrés" STR_DELETE_CACHE: "Esborra la memòria cau del llibre" +STR_SAVE_CLIPPING: "Desa la selecció" STR_DELETE: "Esborra" STR_DISPLAY_QR: "Mostra la pàgina com a QR" STR_CHAPTER_PREFIX: "Capítol: " diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 69921ca253..d3ef21699f 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -230,6 +230,7 @@ STR_GO_TO_PERCENT: "Přejít na %" STR_GO_HOME_BUTTON: "Přejít Domů" STR_SYNC_PROGRESS: "Průběh synchronizace" STR_DELETE_CACHE: "Smazat mezipaměť knihy" +STR_SAVE_CLIPPING: "Uložit výběr" STR_DELETE: "Smazat" STR_CHAPTER_PREFIX: "Kapitola:" STR_PAGES_SEPARATOR: "stránek |" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index 02ac28644e..1fc8faeba0 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -253,6 +253,7 @@ STR_GO_TO_PERCENT: "Gå til %" STR_GO_HOME_BUTTON: "Gå til start" STR_SYNC_PROGRESS: "Synkroniser fremskridt" STR_DELETE_CACHE: "Slet bogcache" +STR_SAVE_CLIPPING: "Gem klip" STR_DELETE: "Slet" STR_DISPLAY_QR: "Vis side som QR" STR_CHAPTER_PREFIX: "Kapitel: " diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index abe05da9f9..0a608f3a5f 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -253,6 +253,7 @@ STR_GO_TO_PERCENT: "Ga naar %" STR_GO_HOME_BUTTON: "Naar Home" STR_SYNC_PROGRESS: "Voortgang synchroniseren" STR_DELETE_CACHE: "Boekcache verwijderen" +STR_SAVE_CLIPPING: "Selectie opslaan" STR_DELETE: "Verwijder" STR_DISPLAY_QR: "Pagina als QR tonen" STR_CHAPTER_PREFIX: "Hoofdstuk: " diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 64316bcf42..220644b06f 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -257,6 +257,7 @@ STR_GO_TO_PERCENT: "Go to %" STR_GO_HOME_BUTTON: "Go Home" STR_SYNC_PROGRESS: "Sync Progress" STR_DELETE_CACHE: "Delete Book Cache" +STR_SAVE_CLIPPING: "Save Clipping" STR_DELETE: "Delete" STR_DISPLAY_QR: "Show page as QR" STR_CHAPTER_PREFIX: "Chapter: " diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 87ff1ee19e..89e445cfc3 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -230,6 +230,7 @@ STR_GO_TO_PERCENT: "Siirry kohtaan %" STR_GO_HOME_BUTTON: "Siirry kotiin" STR_SYNC_PROGRESS: "Synkronoi edistyminen" STR_DELETE_CACHE: "Poista kirjan välimuisti" +STR_SAVE_CLIPPING: "Tallenna leike" STR_CHAPTER_PREFIX: "Luku: " STR_PAGES_SEPARATOR: " sivua | " STR_BOOK_PREFIX: "Kirja: " diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 3aa0293de8..83ab964fe3 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -253,6 +253,7 @@ STR_GO_TO_PERCENT: "Aller à %" STR_GO_HOME_BUTTON: "Retour Accueil" STR_SYNC_PROGRESS: "Synchro progression" STR_DELETE_CACHE: "Supprimer cache livre" +STR_SAVE_CLIPPING: "Enregistrer la sélection" STR_DELETE: "Supprimer" STR_DISPLAY_QR: "Afficher la page en QR" STR_CHAPTER_PREFIX: "Chapitre : " diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index ee29fc0087..a759ca3465 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -253,6 +253,7 @@ STR_GO_TO_PERCENT: "Gehe zu %" STR_GO_HOME_BUTTON: "Zum Anfang" STR_SYNC_PROGRESS: "Fortschritt synchronisieren" STR_DELETE_CACHE: "Buch-Cache leeren" +STR_SAVE_CLIPPING: "Auswahl speichern" STR_DISPLAY_QR: "Seite als QR anzeigen" STR_DELETE: "Löschen" STR_REMOVE: "Entfernen" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index b23f33e7c8..877760ab43 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -253,6 +253,7 @@ STR_GO_TO_PERCENT: "Ugrás %-ra" STR_GO_HOME_BUTTON: "Főoldalra" STR_SYNC_PROGRESS: "Haladás szinkronizálása" STR_DELETE_CACHE: "Könyv gyorsítótár törlése" +STR_SAVE_CLIPPING: "Kijelölés mentése" STR_DELETE: "Törlés" STR_DISPLAY_QR: "Oldal megjelenítése QR-ként" STR_CHAPTER_PREFIX: "Fejezet: " diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 0da638f5c7..87b47e30a6 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -255,6 +255,7 @@ STR_SYNC_PROGRESS: "Sincronizza avanzamento" STR_DELETE_CACHE: "Elimina cache libro" STR_DELETE: "Elimina" STR_DISPLAY_QR: "Mostra pagina come QR" +STR_SAVE_CLIPPING: "Salva selezione" STR_CHAPTER_PREFIX: "Capitolo: " STR_PAGES_SEPARATOR: " pagine | " STR_BOOK_PREFIX: "Libro: " diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 29fe7b8fac..557b21743e 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -229,6 +229,7 @@ STR_GO_TO_PERCENT: "%-ке өту" STR_GO_HOME_BUTTON: "Басты бетке өту" STR_SYNC_PROGRESS: "Үлгерімді синхрондау" STR_DELETE_CACHE: "Кітап кэшін жою" +STR_SAVE_CLIPPING: "Үзінді сақтау" STR_CHAPTER_PREFIX: "Тарау: " STR_PAGES_SEPARATOR: " бет | " STR_BOOK_PREFIX: "Кітап: " diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index e72d554bb5..7da6df2f65 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -253,6 +253,7 @@ STR_GO_TO_PERCENT: "Eiti į %" STR_GO_HOME_BUTTON: "Pradžia" STR_SYNC_PROGRESS: "Sinchr. progresą" STR_DELETE_CACHE: "Trinti talpyklą" +STR_SAVE_CLIPPING: "Išsaugoti ištrauką" STR_DELETE: "Trinti" STR_DISPLAY_QR: "QR kodas" STR_CHAPTER_PREFIX: "Sk: " diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 2b5867ce2b..2c233a5120 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -253,6 +253,7 @@ STR_GO_TO_PERCENT: "Idź do %" STR_GO_HOME_BUTTON: "Wróć do głównego ekranu" STR_SYNC_PROGRESS: "Postęp synchronizacji" STR_DELETE_CACHE: "Usuń pamięć podręczną książek" +STR_SAVE_CLIPPING: "Zapisz fragment" STR_DELETE: "Usuń" STR_DISPLAY_QR: "Pokaż stronę jako kod QR" STR_CHAPTER_PREFIX: "Rozdział: " diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 6af6f21f01..4ca10c8164 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -230,6 +230,7 @@ STR_GO_TO_PERCENT: "Ir para %" STR_GO_HOME_BUTTON: "Ir para o início" STR_SYNC_PROGRESS: "Sincronizar progresso" STR_DELETE_CACHE: "Excluir cache do livro" +STR_SAVE_CLIPPING: "Salvar trecho" STR_DELETE: "Excluir" STR_CHAPTER_PREFIX: "Capítulo:" STR_PAGES_SEPARATOR: "páginas |" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 59b70f4174..4523a3e65a 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -253,6 +253,7 @@ STR_GO_TO_PERCENT: "Săriţi la %" STR_GO_HOME_BUTTON: "Acasă" STR_SYNC_PROGRESS: "Progres sincronizare" STR_DELETE_CACHE: "Ştergere cache cărţi" +STR_SAVE_CLIPPING: "Salvează selecţia" STR_DELETE: "Ştergeți" STR_DISPLAY_QR: "Afişați pagina ca cod QR" STR_CHAPTER_PREFIX: "Capitol: " diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index bb5f263bb7..274dbe3563 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -256,6 +256,7 @@ STR_GO_TO_PERCENT: "Перейти к %" STR_GO_HOME_BUTTON: "На главную" STR_SYNC_PROGRESS: "Синхронизировать прогресс" STR_DELETE_CACHE: "Удалить кэш книги" +STR_SAVE_CLIPPING: "Сохранить фрагмент" STR_DELETE: "Удалить" STR_CHAPTER_PREFIX: "Глава: " STR_DISPLAY_QR: "Показать страницу в виде QR-кода" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 6bf3a91a48..a7b6ea7f74 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -253,6 +253,7 @@ STR_GO_TO_PERCENT: "Pojdi na %" STR_GO_HOME_BUTTON: "Pojdi domov" STR_SYNC_PROGRESS: "Sinhroniziraj napredek" STR_DELETE_CACHE: "Izbriši predpomnilnik knjige" +STR_SAVE_CLIPPING: "Shrani odlomek" STR_DELETE: "Izbriši" STR_DISPLAY_QR: "Prikaži stran kot QR" STR_CHAPTER_PREFIX: "Poglavje: " diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 13643feb87..74c15380b2 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -253,6 +253,7 @@ STR_GO_TO_PERCENT: "Ir a %" STR_GO_HOME_BUTTON: "Volver al menú Inicio" STR_SYNC_PROGRESS: "Sincronizar progreso de lectura" STR_DELETE_CACHE: "Borrar caché del libro" +STR_SAVE_CLIPPING: "Guardar selección" STR_DELETE: "Borrar" STR_DISPLAY_QR: "Mostrar página como QR" STR_CHAPTER_PREFIX: "Cap.: " diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index ff984dcb0c..b44689fb19 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -257,6 +257,7 @@ STR_GO_TO_PERCENT: "Gå till %" STR_GO_HOME_BUTTON: "Gå Hem" STR_SYNC_PROGRESS: "Synkroniseringsframsteg" STR_DELETE_CACHE: "Radera bokcache" +STR_SAVE_CLIPPING: "Spara klipp" STR_DELETE: "Radera" STR_DISPLAY_QR: "Visa sida som QR-kod" STR_CHAPTER_PREFIX: "Kapitel:" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 3f65b2ec1e..ff5b325eee 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -230,6 +230,7 @@ STR_GO_TO_PERCENT: "%'ye git" STR_GO_HOME_BUTTON: "Ana Sayfaya Git" STR_SYNC_PROGRESS: "Okuma İlerlemesini Senkronize Et" STR_DELETE_CACHE: "Kitap Önbelleğini Sil" +STR_SAVE_CLIPPING: "Kırpıyı Kaydet" STR_CHAPTER_PREFIX: "Bölüm: " STR_PAGES_SEPARATOR: " sayfa | " STR_BOOK_PREFIX: "Kitap: " diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index b2cab49901..7ef09bf290 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -254,6 +254,7 @@ STR_GO_TO_PERCENT: "Перейти до %" STR_GO_HOME_BUTTON: "На головну" STR_SYNC_PROGRESS: "Прогрес синхронізації" STR_DELETE_CACHE: "Видалити кеш книги" +STR_SAVE_CLIPPING: "Зберегти уривок" STR_DELETE: "Видалити" STR_DISPLAY_QR: "Показати сторінку як QR-код" STR_CHAPTER_PREFIX: "Розділ: " diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index 665df5356b..3f721658e1 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -58,6 +58,11 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen); } +void HalDisplay::displayWindow(int x, int y, int w, int h) { + einkDisplay.displayWindow(static_cast(x), static_cast(y), + static_cast(w), static_cast(h)); +} + void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) { if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) { einkDisplay.requestResync(1); diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index a0a7f92083..01ee7e0687 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -34,6 +34,7 @@ class HalDisplay { bool fromProgmem = false) const; void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); + void displayWindow(int x, int y, int w, int h); void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); // Power management diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index 0416285eee..79d9284f13 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -50,8 +50,12 @@ struct FootnoteResult { std::string href; }; +struct ClippingResult { + std::string text; +}; + using ResultVariant = std::variant; + PageResult, SyncResult, NetworkModeResult, FootnoteResult, ClippingResult>; struct ActivityResult { bool isCancelled = false; diff --git a/src/activities/reader/ClipSelectionActivity.cpp b/src/activities/reader/ClipSelectionActivity.cpp new file mode 100644 index 0000000000..823d3a3fd9 --- /dev/null +++ b/src/activities/reader/ClipSelectionActivity.cpp @@ -0,0 +1,239 @@ +#include "ClipSelectionActivity.h" + +#include +#include +#include +#include + +#include +#include + +#include "../ActivityResult.h" +#include "MappedInputManager.h" +#include "components/UITheme.h" + +ClipSelectionActivity::ClipSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + std::vector words, std::string bookTitle, std::string author, + std::string chapterTitle, int pageNumber, int fontId, Section& section, + int startPageInSection, int marginTop, int marginLeft) + : Activity("ClipSelection", renderer, mappedInput), + words(std::move(words)), + bookTitle(std::move(bookTitle)), + author(std::move(author)), + chapterTitle(std::move(chapterTitle)), + pageNumber(pageNumber), + fontId(fontId), + section(section), + startPageInSection(startPageInSection), + marginTop(marginTop), + marginLeft(marginLeft) {} + +void ClipSelectionActivity::onEnter() { + Activity::onEnter(); + + if (words.empty()) { + LOG_ERR("CLIP", "No words available for selection"); + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + return; + } + + savedBufferSize = renderer.getBufferSize(); + savedBuffer = static_cast(malloc(savedBufferSize)); + if (!savedBuffer) { + LOG_ERR("CLIP", "malloc failed: %u bytes", savedBufferSize); + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + return; + } + + // Re-render page 0 to get a clean framebuffer — the previous activity (menu) + // may still be painted on screen when onEnter() runs. + switchToPage(0); + requestUpdate(); +} + +void ClipSelectionActivity::onExit() { + free(savedBuffer); + savedBuffer = nullptr; + Activity::onExit(); +} + +void ClipSelectionActivity::loop() { + const int total = static_cast(words.size()); + + buttonNavigator.onNextRelease([this, total] { + const int prevPage = words[cursorIdx].pageIdx; + cursorIdx = ButtonNavigator::nextIndex(cursorIdx, total); + if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; + requestUpdate(); + }); + + buttonNavigator.onNextContinuous([this] { + const int prevPage = words[cursorIdx].pageIdx; + cursorIdx = lineEndForward(cursorIdx); + if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; + requestUpdate(); + }); + + buttonNavigator.onPreviousRelease([this, total] { + const int prevPage = words[cursorIdx].pageIdx; + cursorIdx = ButtonNavigator::previousIndex(cursorIdx, total); + if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; + requestUpdate(); + }); + + buttonNavigator.onPreviousContinuous([this] { + const int prevPage = words[cursorIdx].pageIdx; + cursorIdx = lineEndBackward(cursorIdx); + if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; + requestUpdate(); + }); + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (startMarkIdx == -1) { + startMarkIdx = cursorIdx; + requestUpdate(); + } else { + const int from = std::min(startMarkIdx, cursorIdx); + const int to = std::max(startMarkIdx, cursorIdx); + std::string text; + for (int i = from; i <= to; ++i) { + if (!text.empty()) text += ' '; + text += words[i].text; + } + ActivityResult result; + result.data = ClippingResult{std::move(text)}; + setResult(std::move(result)); + finish(); + } + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + if (startMarkIdx != -1) { + startMarkIdx = -1; + requestUpdate(); + } else { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + } + } +} + +void ClipSelectionActivity::render(RenderLock&&) { + if (!savedBuffer) return; + + if (needsPageSwitch) { + switchToPage(words[cursorIdx].pageIdx); + needsPageSwitch = false; + } + + // Restore the saved page framebuffer, then draw highlights on top + memcpy(renderer.getFrameBuffer(), savedBuffer, savedBufferSize); + drawHighlights(); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(HalDisplay::FAST_REFRESH); +} + +void ClipSelectionActivity::switchToPage(int pageIdx) { + section.currentPage = startPageInSection + pageIdx; + auto page = section.loadPageFromSectionFile(); + if (!page) { + LOG_ERR("CLIP", "Failed to load page %d for display", pageIdx); + return; + } + + renderer.clearScreen(); + page->render(renderer, fontId, marginLeft, marginTop); + // displayBuffer is intentionally omitted here — render() always controls the final display call + memcpy(savedBuffer, renderer.getFrameBuffer(), savedBufferSize); + currentDisplayPage = pageIdx; +} + +void ClipSelectionActivity::drawHighlights() { + // Draw selection range (words on the currently displayed page only) + if (startMarkIdx != -1) { + const int from = std::min(startMarkIdx, cursorIdx); + const int to = std::max(startMarkIdx, cursorIdx); + for (int i = from; i <= to; ++i) { + if (i == cursorIdx) continue; + if (words[i].pageIdx != currentDisplayPage) continue; + renderer.fillRectDither(words[i].x, words[i].y, words[i].w, words[i].h, Color::LightGray); + renderer.drawText(fontId, words[i].x, words[i].y, words[i].text.c_str(), true); + } + } + + // Draw cursor highlight (always on top) + const auto& cw = words[cursorIdx]; + if (cw.pageIdx == currentDisplayPage) { + renderer.fillRectDither(cw.x, cw.y, cw.w, cw.h, Color::LightGray); + renderer.drawText(fontId, cw.x, cw.y, cw.text.c_str(), true); + } +} + +ClipSelectionActivity::Rect ClipSelectionActivity::alignedRect(int x, int y, int w, int h) const { + const int alignedX = (x / 8) * 8; + const int alignedW = ((x + w + 7) / 8) * 8 - alignedX; + return {alignedX, y, alignedW, h}; +} + +int ClipSelectionActivity::lineEndForward(int idx) const { + const int total = static_cast(words.size()); + const int lineY = words[idx].y; + const int page = words[idx].pageIdx; + + // Find last word on the same line + int last = idx; + for (int i = idx + 1; i < total; ++i) { + if (words[i].pageIdx != page || words[i].y != lineY) break; + last = i; + } + + // Already at line end — jump to end of next line + if (last == idx && idx + 1 < total) { + const int nextY = words[idx + 1].y; + const int nextPage = words[idx + 1].pageIdx; + last = idx + 1; + for (int i = idx + 2; i < total; ++i) { + if (words[i].pageIdx != nextPage || words[i].y != nextY) break; + last = i; + } + } + + return last; +} + +int ClipSelectionActivity::lineEndBackward(int idx) const { + const int lineY = words[idx].y; + const int page = words[idx].pageIdx; + + // Find first word on the same line + int first = idx; + for (int i = idx - 1; i >= 0; --i) { + if (words[i].pageIdx != page || words[i].y != lineY) break; + first = i; + } + + // Already at line start — jump to start of previous line + if (first == idx && idx - 1 >= 0) { + const int prevY = words[idx - 1].y; + const int prevPage = words[idx - 1].pageIdx; + first = idx - 1; + for (int i = idx - 2; i >= 0; --i) { + if (words[i].pageIdx != prevPage || words[i].y != prevY) break; + first = i; + } + } + + return first; +} diff --git a/src/activities/reader/ClipSelectionActivity.h b/src/activities/reader/ClipSelectionActivity.h new file mode 100644 index 0000000000..29848a53ed --- /dev/null +++ b/src/activities/reader/ClipSelectionActivity.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +#include +#include + +#include "../Activity.h" +#include "util/ButtonNavigator.h" + +class ClipSelectionActivity final : public Activity { + public: + struct WordRef { + int x, y, w, h; + int pageIdx; // 0 = current, 1 = next, 2 = page after next + std::string text; + }; + + ClipSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::vector words, + std::string bookTitle, std::string author, std::string chapterTitle, int pageNumber, + int fontId, Section& section, int startPageInSection, int marginTop, int marginLeft); + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + bool isReaderActivity() const override { return true; } + + private: + struct Rect { + int x, y, w, h; + }; + + std::vector words; + std::string bookTitle; + std::string author; + std::string chapterTitle; + int pageNumber; + int fontId; + + Section& section; + int startPageInSection; + int marginTop; + int marginLeft; + + uint8_t* savedBuffer = nullptr; + size_t savedBufferSize = 0; + int currentDisplayPage = 0; + + int cursorIdx = 0; + int startMarkIdx = -1; + bool needsPageSwitch = false; + bool initialRender = true; + + ButtonNavigator buttonNavigator; + + Rect alignedRect(int x, int y, int w, int h) const; + void switchToPage(int pageIdx); + void drawHighlights(); + int lineEndForward(int idx) const; + int lineEndBackward(int idx) const; +}; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index bd700b761a..2e3a3afc59 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -25,6 +25,8 @@ #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" +#include "ClipSelectionActivity.h" +#include "clippings/ClippingsManager.h" #include "util/ScreenshotUtil.h" namespace { @@ -398,6 +400,77 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); break; } + case EpubReaderMenuActivity::MenuAction::SAVE_CLIPPING: { + if (section && epub) { + // Compute margins matching the reader render pass + int mTop, mRight, mBottom, mLeft; + renderer.getOrientedViewableTRBL(&mTop, &mRight, &mBottom, &mLeft); + mTop += SETTINGS.screenMargin; + mLeft += SETTINGS.screenMargin; + + const int readerFontId = SETTINGS.getReaderFontId(); + const int lineH = renderer.getLineHeight(readerFontId); + const int startPage = section->currentPage; + const int pagesToLoad = std::min(3, section->pageCount - startPage); + + std::vector words; + words.reserve(pagesToLoad * 60); // rough estimate + + for (int pi = 0; pi < pagesToLoad; ++pi) { + section->currentPage = startPage + pi; + auto page = section->loadPageFromSectionFile(); + if (!page) break; + + for (const auto& el : page->elements) { + if (el->getTag() != TAG_PageLine) continue; + const auto& line = static_cast(*el); + if (!line.getBlock()) continue; + const auto& block = *line.getBlock(); + const auto& xpos = block.getWordXpos(); + const auto& wlist = block.getWords(); + + for (int i = 0; i < static_cast(wlist.size()); ++i) { + const int wx = mLeft + line.xPos + xpos[i]; + const int wy = mTop + line.yPos; + const int ww = (i + 1 < static_cast(xpos.size())) + ? static_cast(xpos[i + 1]) - static_cast(xpos[i]) + : renderer.getTextWidth(readerFontId, wlist[i].c_str()); + if (ww > 0) { + words.push_back({wx, wy, ww, lineH, pi, wlist[i]}); + } + } + } + } + section->currentPage = startPage; + + if (!words.empty()) { + std::string chapterTitle; + const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex); + if (tocIdx >= 0) chapterTitle = epub->getTocItem(tocIdx).title; + + startActivityForResult( + std::make_unique(renderer, mappedInput, std::move(words), epub->getTitle(), + epub->getAuthor(), chapterTitle, startPage + 1, readerFontId, + *section, startPage, mTop, mLeft), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& clip = std::get(result.data); + if (!clip.text.empty()) { + std::string chapterTitle; + const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex); + if (tocIdx >= 0) chapterTitle = epub->getTocItem(tocIdx).title; + ClippingsManager::saveClipping(epub->getTitle(), epub->getAuthor(), chapterTitle, + section ? section->currentPage + 1 : 0, clip.text); + } + } + requestUpdate(); + }); + } else { + requestUpdate(); + } + } + break; + } case EpubReaderMenuActivity::MenuAction::SYNC: { if (KOREADER_STORE.hasCredentials()) { const int currentPage = section ? section->currentPage : nextPageNumber; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 1d95d9b7a1..1dc329973b 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -21,7 +21,7 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu std::vector EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) { std::vector items; - items.reserve(10); + items.reserve(11); items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); if (hasFootnotes) { items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES}); @@ -34,6 +34,7 @@ std::vector EpubReaderMenuActivity::buildMenuI items.push_back({MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON}); items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS}); items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}); + items.push_back({MenuAction::SAVE_CLIPPING, StrId::STR_SAVE_CLIPPING}); return items; } diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 3937d62c72..edbb6afb66 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -21,7 +21,8 @@ class EpubReaderMenuActivity final : public Activity { DISPLAY_QR, GO_HOME, SYNC, - DELETE_CACHE + DELETE_CACHE, + SAVE_CLIPPING }; explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, diff --git a/src/clippings/ClippingsManager.cpp b/src/clippings/ClippingsManager.cpp new file mode 100644 index 0000000000..aa8bdae722 --- /dev/null +++ b/src/clippings/ClippingsManager.cpp @@ -0,0 +1,47 @@ +#include "ClippingsManager.h" + +#include +#include +#include + +bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::string& author, + const std::string& chapterTitle, int pageNumber, + const std::string& selectedText) { + HalFile file = Storage.open(CLIPPINGS_PATH, O_RDWR | O_CREAT | O_AT_END); + if (!file) { + LOG_ERR("CLIP", "Failed to open %s for append", CLIPPINGS_PATH); + return false; + } + + // Header line: "Title / Author" + char header[128]; + snprintf(header, sizeof(header), "%s / %s\n", bookTitle.c_str(), author.c_str()); + + // Location line: "Chapter: X | Page N" + char location[128]; + if (!chapterTitle.empty()) { + snprintf(location, sizeof(location), "Chapter: %s | Page %d\n", chapterTitle.c_str(), pageNumber); + } else { + snprintf(location, sizeof(location), "Page %d\n", pageNumber); + } + + // Body: quoted text, trimmed to 2000 chars to avoid writing huge pages + static constexpr size_t MAX_TEXT = 2000; + const size_t textLen = selectedText.size() < MAX_TEXT ? selectedText.size() : MAX_TEXT; + + char quote[8]; + snprintf(quote, sizeof(quote), "\n\""); + + char separator[] = "\"\n\n==========\n\n"; + + file.write(header, strlen(header)); + file.write(location, strlen(location)); + file.write(quote, strlen(quote)); + file.write(selectedText.c_str(), textLen); + file.write(separator, strlen(separator)); + file.flush(); + file.close(); + + LOG_DBG("CLIP", "Saved clipping to %s (%zu chars)", CLIPPINGS_PATH, textLen); + return true; +} diff --git a/src/clippings/ClippingsManager.h b/src/clippings/ClippingsManager.h new file mode 100644 index 0000000000..fc10fff22f --- /dev/null +++ b/src/clippings/ClippingsManager.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +class ClippingsManager { + public: + // Appends a clipping entry to /clippings.txt on the SD card. + // Returns false if the SD write fails. + static bool saveClipping(const std::string& bookTitle, const std::string& author, + const std::string& chapterTitle, int pageNumber, + const std::string& selectedText); + + static constexpr const char* CLIPPINGS_PATH = "/clippings.txt"; +}; From 26958a6926d35fc3bba55a6dc1c495c8d2c2459d Mon Sep 17 00:00:00 2001 From: Pavl Zubenko Date: Wed, 22 Apr 2026 21:15:45 +0300 Subject: [PATCH 02/21] fix: resolve CI failures for clang-format and cppcheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply clang-format-21 formatting to all modified files - Fix variable shadowing in EpubReaderActivity lambda (chapterTitle/tocIdx → clipChapterTitle/clipTocIdx) to pass cppcheck style check - Replace snprintf with constexpr initialization in ClippingsManager for the quote/separator constants --- lib/hal/HalDisplay.cpp | 4 ++-- src/activities/reader/ClipSelectionActivity.cpp | 6 ++++-- src/activities/reader/ClipSelectionActivity.h | 4 ++-- src/activities/reader/EpubReaderActivity.cpp | 16 ++++++++-------- src/clippings/ClippingsManager.cpp | 9 +++------ src/clippings/ClippingsManager.h | 5 ++--- 6 files changed, 21 insertions(+), 23 deletions(-) diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index 3f721658e1..079f90eacb 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -59,8 +59,8 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) } void HalDisplay::displayWindow(int x, int y, int w, int h) { - einkDisplay.displayWindow(static_cast(x), static_cast(y), - static_cast(w), static_cast(h)); + einkDisplay.displayWindow(static_cast(x), static_cast(y), static_cast(w), + static_cast(h)); } void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) { diff --git a/src/activities/reader/ClipSelectionActivity.cpp b/src/activities/reader/ClipSelectionActivity.cpp index 823d3a3fd9..737b5b992c 100644 --- a/src/activities/reader/ClipSelectionActivity.cpp +++ b/src/activities/reader/ClipSelectionActivity.cpp @@ -168,7 +168,8 @@ void ClipSelectionActivity::drawHighlights() { for (int i = from; i <= to; ++i) { if (i == cursorIdx) continue; if (words[i].pageIdx != currentDisplayPage) continue; - renderer.fillRectDither(words[i].x, words[i].y, words[i].w, words[i].h, Color::LightGray); + const auto r = alignedRect(words[i].x, words[i].y, words[i].w, words[i].h); + renderer.fillRectDither(r.x, r.y, r.w, r.h, Color::LightGray); renderer.drawText(fontId, words[i].x, words[i].y, words[i].text.c_str(), true); } } @@ -176,7 +177,8 @@ void ClipSelectionActivity::drawHighlights() { // Draw cursor highlight (always on top) const auto& cw = words[cursorIdx]; if (cw.pageIdx == currentDisplayPage) { - renderer.fillRectDither(cw.x, cw.y, cw.w, cw.h, Color::LightGray); + const auto r = alignedRect(cw.x, cw.y, cw.w, cw.h); + renderer.fillRectDither(r.x, r.y, r.w, r.h, Color::LightGray); renderer.drawText(fontId, cw.x, cw.y, cw.text.c_str(), true); } } diff --git a/src/activities/reader/ClipSelectionActivity.h b/src/activities/reader/ClipSelectionActivity.h index 29848a53ed..b86e69b2fd 100644 --- a/src/activities/reader/ClipSelectionActivity.h +++ b/src/activities/reader/ClipSelectionActivity.h @@ -18,8 +18,8 @@ class ClipSelectionActivity final : public Activity { }; ClipSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::vector words, - std::string bookTitle, std::string author, std::string chapterTitle, int pageNumber, - int fontId, Section& section, int startPageInSection, int marginTop, int marginLeft); + std::string bookTitle, std::string author, std::string chapterTitle, int pageNumber, int fontId, + Section& section, int startPageInSection, int marginTop, int marginLeft); void onEnter() override; void onExit() override; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 2e3a3afc59..35fb3f8435 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -12,6 +12,7 @@ #include +#include "ClipSelectionActivity.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" @@ -23,10 +24,9 @@ #include "QrDisplayActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" +#include "clippings/ClippingsManager.h" #include "components/UITheme.h" #include "fontIds.h" -#include "ClipSelectionActivity.h" -#include "clippings/ClippingsManager.h" #include "util/ScreenshotUtil.h" namespace { @@ -450,16 +450,16 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction startActivityForResult( std::make_unique(renderer, mappedInput, std::move(words), epub->getTitle(), - epub->getAuthor(), chapterTitle, startPage + 1, readerFontId, - *section, startPage, mTop, mLeft), + epub->getAuthor(), chapterTitle, startPage + 1, readerFontId, + *section, startPage, mTop, mLeft), [this](const ActivityResult& result) { if (!result.isCancelled) { const auto& clip = std::get(result.data); if (!clip.text.empty()) { - std::string chapterTitle; - const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex); - if (tocIdx >= 0) chapterTitle = epub->getTocItem(tocIdx).title; - ClippingsManager::saveClipping(epub->getTitle(), epub->getAuthor(), chapterTitle, + std::string clipChapterTitle; + const int clipTocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex); + if (clipTocIdx >= 0) clipChapterTitle = epub->getTocItem(clipTocIdx).title; + ClippingsManager::saveClipping(epub->getTitle(), epub->getAuthor(), clipChapterTitle, section ? section->currentPage + 1 : 0, clip.text); } } diff --git a/src/clippings/ClippingsManager.cpp b/src/clippings/ClippingsManager.cpp index aa8bdae722..a1a454993e 100644 --- a/src/clippings/ClippingsManager.cpp +++ b/src/clippings/ClippingsManager.cpp @@ -5,8 +5,7 @@ #include bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::string& author, - const std::string& chapterTitle, int pageNumber, - const std::string& selectedText) { + const std::string& chapterTitle, int pageNumber, const std::string& selectedText) { HalFile file = Storage.open(CLIPPINGS_PATH, O_RDWR | O_CREAT | O_AT_END); if (!file) { LOG_ERR("CLIP", "Failed to open %s for append", CLIPPINGS_PATH); @@ -29,10 +28,8 @@ bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::str static constexpr size_t MAX_TEXT = 2000; const size_t textLen = selectedText.size() < MAX_TEXT ? selectedText.size() : MAX_TEXT; - char quote[8]; - snprintf(quote, sizeof(quote), "\n\""); - - char separator[] = "\"\n\n==========\n\n"; + static constexpr char quote[] = "\n\""; + static constexpr char separator[] = "\"\n\n==========\n\n"; file.write(header, strlen(header)); file.write(location, strlen(location)); diff --git a/src/clippings/ClippingsManager.h b/src/clippings/ClippingsManager.h index fc10fff22f..dbea0a7bf0 100644 --- a/src/clippings/ClippingsManager.h +++ b/src/clippings/ClippingsManager.h @@ -6,9 +6,8 @@ class ClippingsManager { public: // Appends a clipping entry to /clippings.txt on the SD card. // Returns false if the SD write fails. - static bool saveClipping(const std::string& bookTitle, const std::string& author, - const std::string& chapterTitle, int pageNumber, - const std::string& selectedText); + static bool saveClipping(const std::string& bookTitle, const std::string& author, const std::string& chapterTitle, + int pageNumber, const std::string& selectedText); static constexpr const char* CLIPPINGS_PATH = "/clippings.txt"; }; From 4d9e306c67eb3b19fcf0f5d72c0f1ed5b525eff1 Mon Sep 17 00:00:00 2001 From: Pavl Zubenko Date: Wed, 22 Apr 2026 21:21:32 +0300 Subject: [PATCH 03/21] fix: address coderabbitai review for clipping feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClipSelectionActivity: save and restore section.currentPage in onEnter/onExit to prevent stale page index in parent activity - EpubReaderActivity: capture chapterTitle/startPage by value in lambda, eliminating duplicated TOC lookup and unreliable section->currentPage read - GfxRenderer::displayWindow: transform logical→physical coordinates per orientation and align to 8-pixel e-ink byte boundaries - HalDisplay::displayWindow: clamp negative/out-of-bounds inputs before casting to uint16_t to prevent wrap-around values reaching the driver - ClippingsManager: check write() return values; save to /My Clippings.txt for Kindle-compatible filename - Turkish i18n: fix STR_SAVE_CLIPPING to "Seçimi Kaydet" (save selection) --- lib/GfxRenderer/GfxRenderer.cpp | 33 ++++++++++++++++++- lib/I18n/translations/turkish.yaml | 2 +- lib/hal/HalDisplay.cpp | 5 +++ .../reader/ClipSelectionActivity.cpp | 2 ++ src/activities/reader/ClipSelectionActivity.h | 1 + src/activities/reader/EpubReaderActivity.cpp | 30 ++++++++--------- src/clippings/ClippingsManager.cpp | 22 +++++++++---- src/clippings/ClippingsManager.h | 4 +-- 8 files changed, 72 insertions(+), 27 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 5ef700ed1b..4aa9c72bbd 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -898,7 +898,38 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const } void GfxRenderer::displayWindow(int x, int y, int width, int height) const { - display.displayWindow(x, y, width, height); + int phyX, phyY, phyW, phyH; + switch (orientation) { + case Portrait: + phyX = x; + phyY = panelHeight - y - height; + phyW = width; + phyH = height; + break; + case LandscapeClockwise: + phyX = panelWidth - x - width; + phyY = panelHeight - y - height; + phyW = width; + phyH = height; + break; + case PortraitInverted: + phyX = panelWidth - x - width; + phyY = y; + phyW = width; + phyH = height; + break; + case LandscapeCounterClockwise: + default: + phyX = x; + phyY = y; + phyW = width; + phyH = height; + break; + } + // Align to 8-pixel (byte) boundaries required by e-ink panel DMA + const int alignedX = (phyX / 8) * 8; + const int alignedW = ((phyX + phyW + 7) / 8) * 8 - alignedX; + display.displayWindow(alignedX, phyY, alignedW, phyH); } std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index ff5b325eee..896a1cae73 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -230,7 +230,7 @@ STR_GO_TO_PERCENT: "%'ye git" STR_GO_HOME_BUTTON: "Ana Sayfaya Git" STR_SYNC_PROGRESS: "Okuma İlerlemesini Senkronize Et" STR_DELETE_CACHE: "Kitap Önbelleğini Sil" -STR_SAVE_CLIPPING: "Kırpıyı Kaydet" +STR_SAVE_CLIPPING: "Seçimi Kaydet" STR_CHAPTER_PREFIX: "Bölüm: " STR_PAGES_SEPARATOR: " sayfa | " STR_BOOK_PREFIX: "Kitap: " diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index 079f90eacb..17635b4943 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -59,6 +59,11 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) } void HalDisplay::displayWindow(int x, int y, int w, int h) { + if (x < 0) x = 0; + if (y < 0) y = 0; + if (w <= 0 || h <= 0 || x >= DISPLAY_WIDTH || y >= DISPLAY_HEIGHT) return; + if (x + w > DISPLAY_WIDTH) w = DISPLAY_WIDTH - x; + if (y + h > DISPLAY_HEIGHT) h = DISPLAY_HEIGHT - y; einkDisplay.displayWindow(static_cast(x), static_cast(y), static_cast(w), static_cast(h)); } diff --git a/src/activities/reader/ClipSelectionActivity.cpp b/src/activities/reader/ClipSelectionActivity.cpp index 737b5b992c..25a87d7449 100644 --- a/src/activities/reader/ClipSelectionActivity.cpp +++ b/src/activities/reader/ClipSelectionActivity.cpp @@ -40,6 +40,7 @@ void ClipSelectionActivity::onEnter() { return; } + savedSectionPage = section.currentPage; savedBufferSize = renderer.getBufferSize(); savedBuffer = static_cast(malloc(savedBufferSize)); if (!savedBuffer) { @@ -58,6 +59,7 @@ void ClipSelectionActivity::onEnter() { } void ClipSelectionActivity::onExit() { + section.currentPage = savedSectionPage; free(savedBuffer); savedBuffer = nullptr; Activity::onExit(); diff --git a/src/activities/reader/ClipSelectionActivity.h b/src/activities/reader/ClipSelectionActivity.h index b86e69b2fd..4b4b63e4ab 100644 --- a/src/activities/reader/ClipSelectionActivity.h +++ b/src/activities/reader/ClipSelectionActivity.h @@ -47,6 +47,7 @@ class ClipSelectionActivity final : public Activity { uint8_t* savedBuffer = nullptr; size_t savedBufferSize = 0; int currentDisplayPage = 0; + int savedSectionPage = 0; int cursorIdx = 0; int startMarkIdx = -1; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 35fb3f8435..ab3c72839a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -448,23 +448,19 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex); if (tocIdx >= 0) chapterTitle = epub->getTocItem(tocIdx).title; - startActivityForResult( - std::make_unique(renderer, mappedInput, std::move(words), epub->getTitle(), - epub->getAuthor(), chapterTitle, startPage + 1, readerFontId, - *section, startPage, mTop, mLeft), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& clip = std::get(result.data); - if (!clip.text.empty()) { - std::string clipChapterTitle; - const int clipTocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex); - if (clipTocIdx >= 0) clipChapterTitle = epub->getTocItem(clipTocIdx).title; - ClippingsManager::saveClipping(epub->getTitle(), epub->getAuthor(), clipChapterTitle, - section ? section->currentPage + 1 : 0, clip.text); - } - } - requestUpdate(); - }); + startActivityForResult(std::make_unique( + renderer, mappedInput, std::move(words), epub->getTitle(), epub->getAuthor(), + chapterTitle, startPage + 1, readerFontId, *section, startPage, mTop, mLeft), + [this, chapterTitle, startPage](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& clip = std::get(result.data); + if (!clip.text.empty()) { + ClippingsManager::saveClipping(epub->getTitle(), epub->getAuthor(), chapterTitle, + startPage + 1, clip.text); + } + } + requestUpdate(); + }); } else { requestUpdate(); } diff --git a/src/clippings/ClippingsManager.cpp b/src/clippings/ClippingsManager.cpp index a1a454993e..77b4349fe1 100644 --- a/src/clippings/ClippingsManager.cpp +++ b/src/clippings/ClippingsManager.cpp @@ -24,21 +24,31 @@ bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::str snprintf(location, sizeof(location), "Page %d\n", pageNumber); } - // Body: quoted text, trimmed to 2000 chars to avoid writing huge pages + // Body: quoted text, trimmed to 2000 chars to avoid writing huge clippings static constexpr size_t MAX_TEXT = 2000; const size_t textLen = selectedText.size() < MAX_TEXT ? selectedText.size() : MAX_TEXT; static constexpr char quote[] = "\n\""; static constexpr char separator[] = "\"\n\n==========\n\n"; - file.write(header, strlen(header)); - file.write(location, strlen(location)); - file.write(quote, strlen(quote)); - file.write(selectedText.c_str(), textLen); - file.write(separator, strlen(separator)); + const size_t headerLen = strlen(header); + const size_t locationLen = strlen(location); + const size_t quoteLen = strlen(quote); + const size_t separatorLen = strlen(separator); + + bool ok = file.write(header, headerLen) == headerLen; + ok = ok && file.write(location, locationLen) == locationLen; + ok = ok && file.write(quote, quoteLen) == quoteLen; + ok = ok && file.write(selectedText.c_str(), textLen) == textLen; + ok = ok && file.write(separator, separatorLen) == separatorLen; file.flush(); file.close(); + if (!ok) { + LOG_ERR("CLIP", "Failed to write clipping to %s (SD full or removed?)", CLIPPINGS_PATH); + return false; + } + LOG_DBG("CLIP", "Saved clipping to %s (%zu chars)", CLIPPINGS_PATH, textLen); return true; } diff --git a/src/clippings/ClippingsManager.h b/src/clippings/ClippingsManager.h index dbea0a7bf0..54715b1f8a 100644 --- a/src/clippings/ClippingsManager.h +++ b/src/clippings/ClippingsManager.h @@ -4,10 +4,10 @@ class ClippingsManager { public: - // Appends a clipping entry to /clippings.txt on the SD card. + // Appends a clipping entry to /My Clippings.txt on the SD card (Kindle-compatible filename). // Returns false if the SD write fails. static bool saveClipping(const std::string& bookTitle, const std::string& author, const std::string& chapterTitle, int pageNumber, const std::string& selectedText); - static constexpr const char* CLIPPINGS_PATH = "/clippings.txt"; + static constexpr const char* CLIPPINGS_PATH = "/My Clippings.txt"; }; From 198b9d77862b83b14f04988e6818b1e62658b314 Mon Sep 17 00:00:00 2001 From: Stefan Blixten Karlsson Date: Sun, 26 Apr 2026 23:01:36 +0200 Subject: [PATCH 04/21] fix: swedish translations (#1762) ## Summary * Add swedish translation of strings added by "feat: Support for multiple OPDS servers (#1209)" --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- lib/I18n/translations/swedish.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index ff984dcb0c..ca5d2f20a9 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -292,6 +292,12 @@ STR_FOOTNOTES: "Fotnoter" STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan" STR_LINK: "[länk]" STR_SCREENSHOT_BUTTON: "Ta en skärmdump" +STR_ADD_SERVER: "Lägg till server" +STR_SERVER_NAME: "Servernamn" +STR_NO_SERVERS: "Inga OPDS-servrar konfigurerade" +STR_DELETE_SERVER: "Ta bort server" +STR_DELETE_CONFIRM: "Vill du ta bort den här servern?" +STR_OPDS_SERVERS: "OPDS-servrar" STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: " STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)" STR_CRASH_TITLE: "Systemkrasch" From 02822e01b31cc06f31a176020d7e3560acea756b Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Mon, 27 Apr 2026 15:52:09 +0300 Subject: [PATCH 05/21] feat: include short SHA in CROSSPOINT_VERSION (#1728) ## Summary * Includes short SHA in version string for better version tracking, so it looks like `1.1.0-dev-feat-kosync-xpath-05c6cf8` * Closes https://github.com/crosspoint-reader/crosspoint-reader/issues/1247 --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< YES >**_ --- platformio.ini | 2 +- scripts/git_branch.py | 59 ++++++++++++++++++++++++++++++------------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/platformio.ini b/platformio.ini index 75ee95672d..625eaae2a5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -69,7 +69,7 @@ lib_deps = extends = base build_flags = ${base.build_flags} - ; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes current branch) + ; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA) -DENABLE_SERIAL_LOG -DLOG_LEVEL=2 ; Set log level to debug for development builds diff --git a/scripts/git_branch.py b/scripts/git_branch.py index 5ff74a9bdd..77dba53ad5 100644 --- a/scripts/git_branch.py +++ b/scripts/git_branch.py @@ -1,8 +1,8 @@ """ -PlatformIO pre-build script: inject git branch into CROSSPOINT_VERSION for -the default (dev) environment. +PlatformIO pre-build script: inject git branch and short SHA into +CROSSPOINT_VERSION for the default (dev) environment. -Results in a version string like: 1.1.0-dev+feat-koysnc-xpath +Results in a version string like: 1.1.0-dev-feat-kosync-xpath-05c6cf8 Release environments are unaffected; they set CROSSPOINT_VERSION in the ini. """ @@ -16,31 +16,53 @@ def warn(msg): print(f'WARNING [git_branch.py]: {msg}', file=sys.stderr) -def get_git_branch(project_dir): +def run_git_value(project_dir, args, label): try: - branch = subprocess.check_output( - ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], + value = subprocess.check_output( + ['git', *args], text=True, stderr=subprocess.PIPE, cwd=project_dir ).strip() - # Detached HEAD — show the short SHA instead - if branch == 'HEAD': - branch = subprocess.check_output( - ['git', 'rev-parse', '--short', 'HEAD'], - text=True, stderr=subprocess.PIPE, cwd=project_dir - ).strip() # Strip characters that would break a C string literal - return ''.join(c for c in branch if c not in '"\\') + return ''.join(c for c in value if c not in '"\\') except FileNotFoundError: - warn('git not found on PATH; branch suffix will be "unknown"') + warn(f'git not found on PATH; {label} suffix will be "unknown"') return 'unknown' except subprocess.CalledProcessError as e: - warn(f'git command failed (exit {e.returncode}): {e.stderr.strip()}; branch suffix will be "unknown"') + warn( + f'git command failed (exit {e.returncode}): ' + f'{e.stderr.strip()}; {label} suffix will be "unknown"' + ) + return 'unknown' + except OSError as e: + warn( + f'OS error reading git {label}: {e}; ' + f'{label} suffix will be "unknown"' + ) return 'unknown' - except Exception as e: - warn(f'Unexpected error reading git branch: {e}; branch suffix will be "unknown"') + except Exception as e: # pylint: disable=broad-exception-caught + warn( + f'Unexpected error reading git {label}: {e}; ' + f'{label} suffix will be "unknown"' + ) return 'unknown' +def get_git_branch(project_dir): + branch = run_git_value( + project_dir, ['rev-parse', '--abbrev-ref', 'HEAD'], 'branch' + ) + # Detached HEAD has no branch name. + if branch == 'HEAD': + return 'detached' + return branch + + +def get_git_short_sha(project_dir): + return run_git_value( + project_dir, ['rev-parse', '--short', 'HEAD'], 'short SHA' + ) + + def get_base_version(project_dir): ini_path = os.path.join(project_dir, 'platformio.ini') if not os.path.isfile(ini_path): @@ -63,7 +85,8 @@ def inject_version(env): project_dir = env['PROJECT_DIR'] base_version = get_base_version(project_dir) branch = get_git_branch(project_dir) - version_string = f'{base_version}-dev+{branch}' + short_sha = get_git_short_sha(project_dir) + version_string = f'{base_version}-dev-{branch}-{short_sha}' env.Append(CPPDEFINES=[('CROSSPOINT_VERSION', f'\\"{version_string}\\"')]) print(f'CrossPoint build version: {version_string}') From 33386953d955d9cd541af91fa2209c463f581e7e Mon Sep 17 00:00:00 2001 From: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:45:38 +0300 Subject: [PATCH 06/21] feat: enable pio build cache (#1769) ## Summary * **What is the goal of this PR?** Enables the PlatformIO build cache to speed up compilation. ## Additional Context Significant improvement when switching branches, as unchanged files won't need re-compilation. See [Docs](https://docs.platformio.org/en/latest/projectconf/sections/platformio/options/directory/build_cache_dir.html). Note: May need to manually delete .cache folder if updating toolchain or framework. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< NO >**_ --- platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/platformio.ini b/platformio.ini index 625eaae2a5..189ff8d9d3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,6 @@ [platformio] default_envs = default +build_cache_dir = .cache extra_configs = platformio.local.ini [crosspoint] From b8a51522ca4cd1184054bd10cc5f785a999c7919 Mon Sep 17 00:00:00 2001 From: bunsoootchi Date: Tue, 28 Apr 2026 00:01:22 +0800 Subject: [PATCH 07/21] feat(theme): add roundedraff theme and fix sleep cover crop grid artifacts (#918) ## Summary - Add new `roundedraff` theme. - Fix sleep screen artifact where book cover showed grid lines in `Cover` mode with `Crop`. ## Additional Context ## Key changes - Added `src/components/themes/roundedraff/` theme implementation. - Updated sleep cover rendering logic in: - `src/activities/boot_sleep/SleepActivity.cpp` - `src/activities/boot_sleep/SleepActivity.h` - Improved cover generation/regeneration paths in: - `lib/Epub/Epub.cpp` - `lib/Epub/Epub.h` - `lib/Txt/Txt.cpp` - `lib/Txt/Txt.h` ## Verification - Sleep screen set to Book cover - Sleep screen cover mode set to Crop - Confirmed grid artifacts no longer appear on cover sleep screen. ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< YES >**_ --------- Co-authored-by: CaptainFrito Co-authored-by: Zach Nelson Co-authored-by: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com> --- lib/GfxRenderer/GfxRenderer.cpp | 35 ++ lib/GfxRenderer/GfxRenderer.h | 1 + lib/I18n/translations/czech.yaml | 1 + lib/I18n/translations/english.yaml | 1 + lib/I18n/translations/french.yaml | 1 + lib/I18n/translations/german.yaml | 1 + lib/I18n/translations/portuguese.yaml | 1 + lib/I18n/translations/russian.yaml | 1 + lib/I18n/translations/spanish.yaml | 1 + lib/I18n/translations/swedish.yaml | 1 + src/CrossPointSettings.h | 2 +- src/SettingsList.h | 5 +- src/activities/home/HomeActivity.cpp | 18 +- src/components/UITheme.cpp | 6 + src/components/themes/BaseTheme.h | 4 + src/components/themes/lyra/LyraTheme.h | 2 + .../themes/roundedraff/RoundedRaffTheme.cpp | 411 ++++++++++++++++++ .../themes/roundedraff/RoundedRaffTheme.h | 64 +++ 18 files changed, 548 insertions(+), 8 deletions(-) create mode 100644 src/components/themes/roundedraff/RoundedRaffTheme.cpp create mode 100644 src/components/themes/roundedraff/RoundedRaffTheme.h diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index a343badccd..70e282c568 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -493,6 +493,41 @@ void GfxRenderer::fillRectDither(const int x, const int y, const int width, cons } } +void GfxRenderer::maskRoundedRectOutsideCorners(const int x, const int y, const int width, const int height, + const int radius, const Color color) const { + if (radius <= 0 || color == Color::Clear) { + return; + } + + const int rr = radius - 1; + const int rr2 = rr * rr; + for (int dy = 0; dy < radius; dy++) { + for (int dx = 0; dx < radius; dx++) { + const int tx = rr - dx; + const int ty = rr - dy; + if (tx * tx + ty * ty > rr2) { + if (color == Color::White || color == Color::Black) { + bool state = color == Color::Black; + drawPixel(x + dx, y + dy, state); // top-left + drawPixel(x + width - 1 - dx, y + dy, state); // top-right + drawPixel(x + dx, y + height - 1 - dy, state); // bottom-left + drawPixel(x + width - 1 - dx, y + height - 1 - dy, state); // bottom-right + } else if (color == Color::LightGray) { + drawPixelDither(x + dx, y + dy); // top-left + drawPixelDither(x + width - 1 - dx, y + dy); // top-right + drawPixelDither(x + dx, y + height - 1 - dy); // bottom-left + drawPixelDither(x + width - 1 - dx, y + height - 1 - dy); // bottom-right + } else if (color == Color::DarkGray) { + drawPixelDither(x + dx, y + dy); // top-left + drawPixelDither(x + width - 1 - dx, y + dy); // top-right + drawPixelDither(x + dx, y + height - 1 - dy); // bottom-left + drawPixelDither(x + width - 1 - dx, y + height - 1 - dy); // bottom-right + } + } + } + } +} + template void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir) const { if (maxRadius <= 0) return; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e683e3122e..9e24b39e54 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -100,6 +100,7 @@ class GfxRenderer { void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool state) const; void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool roundTopLeft, bool roundTopRight, bool roundBottomLeft, bool roundBottomRight, bool state) const; + void maskRoundedRectOutsideCorners(int x, int y, int width, int height, int radius, Color color = Color::White) const; void fillRect(int x, int y, int width, int height, bool state = true) const; void fillRectDither(int x, int y, int width, int height, Color color) const; void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, Color color) const; diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 69921ca253..9ba06d622b 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -202,6 +202,7 @@ STR_FILTER_CONTRAST: "Kontrast" STR_UI_THEME: "Šablona rozhraní" STR_THEME_CLASSIC: "Klasická" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Oprava blednutí na slunci" STR_REMAP_FRONT_BUTTONS: "Přemapovat přední tlačítka" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 64316bcf42..1aba1ea062 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -228,6 +228,7 @@ STR_BATTERY: "Battery" STR_UI_THEME: "UI Theme" STR_THEME_CLASSIC: "Classic" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix" STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 3aa0293de8..d300b4448f 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -225,6 +225,7 @@ STR_BATTERY: "Batterie" STR_UI_THEME: "Thème interface" STR_THEME_CLASSIC: "Classique" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Correction lisibilité au soleil" STR_REMAP_FRONT_BUTTONS: "Configurer boutons façade" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index ee29fc0087..8fb1711122 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -225,6 +225,7 @@ STR_BATTERY: "Batterie" STR_UI_THEME: "System-Design" STR_THEME_CLASSIC: "Klassisch" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Anti-Verblassen" STR_REMAP_FRONT_BUTTONS: "Vordere Tasten belegen" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 6af6f21f01..7a0fd8febc 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -202,6 +202,7 @@ STR_FILTER_CONTRAST: "Contraste" STR_UI_THEME: "Tema da interface" STR_THEME_CLASSIC: "Clássico" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Ajuste desbotamento ao sol" STR_REMAP_FRONT_BUTTONS: "Remapear botões frontais" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index bb5f263bb7..fdd032d16e 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -225,6 +225,7 @@ STR_BATTERY: "Батарея" STR_UI_THEME: "Тема интерфейса" STR_THEME_CLASSIC: "Классическая" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Компенсация выцветания" STR_REMAP_FRONT_BUTTONS: "Переназначить передние кнопки" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 13643feb87..2a105bf8be 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -226,6 +226,7 @@ STR_UI_THEME: "Interfaz" STR_THEME_CLASSIC: "Clásico" STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_EXTENDED: "Lyra Extendido" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_SUNLIGHT_FADING_FIX: "Corrección de desvanecimiento" STR_REMAP_FRONT_BUTTONS: "Reconfigurar botones frontales" STR_OPDS_BROWSER: "Navegador OPDS" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index ca5d2f20a9..bb58cbdf2f 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -228,6 +228,7 @@ STR_BATTERY: "Batteri" STR_UI_THEME: "Användargränssnittstema" STR_THEME_CLASSIC: "Klassisk" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra utökad" STR_SUNLIGHT_FADING_FIX: "Fix för solskensmattning" STR_REMAP_FRONT_BUTTONS: "Ändra frontknappar" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 7e50ac4fc0..a407e2d2ae 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -132,7 +132,7 @@ class CrossPointSettings { enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT }; // UI Theme - enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2 }; + enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2, ROUNDEDRAFF = 3 }; // Image rendering in EPUB reader enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT }; diff --git a/src/SettingsList.h b/src/SettingsList.h index 70b4c4a36b..aaa00f8a1a 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -31,8 +31,9 @@ inline const std::vector& getSettingsList() { {StrId::STR_PAGES_1, StrId::STR_PAGES_5, StrId::STR_PAGES_10, StrId::STR_PAGES_15, StrId::STR_PAGES_30}, "refreshFrequency", StrId::STR_CAT_DISPLAY), SettingInfo::Enum(StrId::STR_UI_THEME, &CrossPointSettings::uiTheme, - {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_LYRA, StrId::STR_THEME_LYRA_EXTENDED}, "uiTheme", - StrId::STR_CAT_DISPLAY), + {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_LYRA, StrId::STR_THEME_LYRA_EXTENDED, + StrId::STR_THEME_ROUNDEDRAFF}, + "uiTheme", StrId::STR_CAT_DISPLAY), SettingInfo::Toggle(StrId::STR_SUNLIGHT_FADING_FIX, &CrossPointSettings::fadingFix, "fadingFix", StrId::STR_CAT_DISPLAY), diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 4e13382929..ff07b4f0e9 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -218,7 +218,8 @@ void HomeActivity::render(RenderLock&&) { renderer.clearScreen(); bool bufferRestored = coverBufferStored && restoreCoverBuffer(); - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, + metrics.homeContinueReadingInMenu && !recentBooks.empty() ? recentBooks[0].title.c_str() : nullptr); GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored, @@ -234,12 +235,19 @@ void HomeActivity::render(RenderLock&&) { menuIcons.insert(menuIcons.begin() + 2, Library); } + if (metrics.homeContinueReadingInMenu) { + // Insert Continue Reading at the top if enabled in theme + menuItems.insert(menuItems.begin(), tr(STR_CONTINUE_READING)); + menuIcons.insert(menuIcons.begin(), Book); + } + 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()), selectorIndex - recentBooks.size(), + Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset, pageWidth, + pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing + + metrics.homeMenuTopOffset + metrics.buttonHintsHeight)}, + static_cast(menuItems.size()), + metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size(), [&menuItems](int index) { return std::string(menuItems[index]); }, [&menuIcons](int index) { return menuIcons[index]; }); diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 425981a217..7e3a587577 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -11,6 +11,7 @@ #include "components/themes/BaseTheme.h" #include "components/themes/lyra/Lyra3CoversTheme.h" #include "components/themes/lyra/LyraTheme.h" +#include "components/themes/roundedraff/RoundedRaffTheme.h" namespace { constexpr int SKIP_PAGE_MS = 700; @@ -40,6 +41,11 @@ void UITheme::setTheme(CrossPointSettings::UI_THEME type) { currentTheme = std::make_unique(); currentMetrics = &LyraMetrics::values; break; + case CrossPointSettings::UI_THEME::ROUNDEDRAFF: + LOG_DBG("UI", "Using RoundedRaff theme"); + currentTheme = std::make_unique(); + currentMetrics = &RoundedRaffMetrics::values; + break; case CrossPointSettings::UI_THEME::LYRA_3_COVERS: LOG_DBG("UI", "Using Lyra 3 Covers theme"); currentTheme = std::make_unique(); diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 6dd462e7db..e24f8074a3 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -48,6 +48,8 @@ struct ThemeMetrics { int homeCoverHeight; int homeCoverTileHeight; int homeRecentBooksCount; + bool homeContinueReadingInMenu; + int homeMenuTopOffset; int buttonHintsHeight; int sideButtonHintsWidth; @@ -97,6 +99,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .homeCoverHeight = 400, .homeCoverTileHeight = 400, .homeRecentBooksCount = 1, + .homeContinueReadingInMenu = false, + .homeMenuTopOffset = 10, .buttonHintsHeight = 40, .sideButtonHintsWidth = 30, .progressBarHeight = 16, diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index c539cf6b96..eec76b1036 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -25,6 +25,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .homeCoverHeight = 226, .homeCoverTileHeight = 242, .homeRecentBooksCount = 1, + .homeContinueReadingInMenu = false, + .homeMenuTopOffset = 16, .buttonHintsHeight = 40, .sideButtonHintsWidth = 30, .progressBarHeight = 16, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.cpp b/src/components/themes/roundedraff/RoundedRaffTheme.cpp new file mode 100644 index 0000000000..12876f4a6a --- /dev/null +++ b/src/components/themes/roundedraff/RoundedRaffTheme.cpp @@ -0,0 +1,411 @@ +#include "RoundedRaffTheme.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "RecentBooksStore.h" +#include "components/UITheme.h" +#include "components/icons/cover.h" +#include "fontIds.h" + +namespace { +constexpr int kCoverRadius = 18; +constexpr int kMenuRadius = 30; +constexpr int kBottomRadius = 15; +constexpr int kRowRadius = 20; +constexpr int kInteractiveInsetX = 20; +constexpr int kSelectableRowGap = 6; +constexpr int batteryPercentSpacing = 4; +constexpr int kTitleFontId = UI_12_FONT_ID; // Requested main title size: 12px +constexpr int kSubtitleFontId = SMALL_FONT_ID; // Requested subtitle size: 8px +constexpr int kGuideFontId = SMALL_FONT_ID; // Closest available to requested 6px + +void drawScrollBar(const GfxRenderer& renderer, Rect rect, int itemCount, int pageStartIndex, int pageItems) { + if (itemCount <= 0 || pageItems <= 0 || itemCount <= pageItems) { + return; + } + + const int barW = RoundedRaffMetrics::values.scrollBarWidth; + const int barX = rect.x + rect.width - RoundedRaffMetrics::values.scrollBarRightOffset - barW; + const int barY = rect.y; + const int barH = rect.height; + + const int thumbH = std::max(10, (barH * pageItems) / itemCount); + const int maxStart = std::max(1, itemCount - pageItems); + const int maxTravel = std::max(1, barH - thumbH); + const int thumbY = barY + (pageStartIndex * maxTravel) / maxStart; + + renderer.fillRect(barX, thumbY, barW, thumbH); +} + +void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) { + // Top line + renderer.drawLine(x + 1, y, x + battWidth - 3, y); + // Bottom line + renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1); + // Left line + renderer.drawLine(x, y + 1, x, y + rectHeight - 2); + // Battery end + renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2); + renderer.drawPixel(x + battWidth - 1, y + 3); + renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4); + renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5); + + // The +1 is to round up, so that we always fill at least one pixel. + int filledWidth = percentage * (battWidth - 5) / 100 + 1; + if (filledWidth > battWidth - 5) { + filledWidth = battWidth - 5; // Ensure we don't overflow. + } + + renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4); +} + +void drawBatteryRightStable(const GfxRenderer& renderer, Rect iconRect, uint16_t percentage, bool showPercentage) { + // Match BaseTheme::drawBatteryRight layout, but use a stable percentage value for this render. + const int iconY = iconRect.y + 6; + + if (showPercentage) { + const auto percentageText = std::to_string(percentage) + "%"; + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, iconRect.x - textWidth - batteryPercentSpacing, iconRect.y, + percentageText.c_str()); + } + + drawBatteryIcon(renderer, iconRect.x, iconY, RoundedRaffMetrics::values.batteryWidth, iconRect.height, percentage); +} + +std::string sanitizeButtonLabel(std::string label) { + // Remove common directional prefixes/symbols (e.g. "<< Home", unsupported icon glyphs). + while (!label.empty() && !std::isalnum(static_cast(label[0]))) { + label.erase(0, 1); + } + // Trim any extra left spaces. + while (!label.empty() && label[0] == ' ') { + label.erase(0, 1); + } + return label; +} + +} // namespace +int coverWidth = 0; + +void RoundedRaffTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, + const char* subtitle) const { + (void)subtitle; + // Home screen header is custom-rendered in drawRecentBookCover. + if (title == nullptr) { + return; + } + const int sidePadding = RoundedRaffMetrics::values.contentSidePadding; + const int titleX = rect.x + sidePadding; + const int titleY = rect.y + 14; + + const bool showBatteryPercentage = + SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; + const uint16_t percentage = powerManager.getBatteryPercentage(); + const int batteryIconX = rect.x + rect.width - sidePadding - RoundedRaffMetrics::values.batteryWidth; + int batteryGroupLeftX = batteryIconX; + if (showBatteryPercentage) { + const auto percentageText = std::to_string(percentage) + "%"; + batteryGroupLeftX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()) + batteryPercentSpacing; + + // Clear a fixed-width area for the battery percentage to avoid ghosting when digit count changes (e.g. 100% -> + // 99%). + const int maxTextWidth = renderer.getTextWidth(SMALL_FONT_ID, "100%"); + const int clearW = maxTextWidth + batteryPercentSpacing + RoundedRaffMetrics::values.batteryWidth; + const int clearH = std::max(renderer.getTextHeight(SMALL_FONT_ID), RoundedRaffMetrics::values.batteryHeight + 8); + renderer.fillRect(batteryIconX - maxTextWidth - batteryPercentSpacing, rect.y + 14, clearW, clearH, false); + } + + const int maxTextWidth = std::max(0, batteryGroupLeftX - 20 - titleX); + auto headerTitle = renderer.truncatedText(kTitleFontId, title, maxTextWidth, EpdFontFamily::BOLD); + renderer.drawText(kTitleFontId, titleX, titleY, headerTitle.c_str(), true, EpdFontFamily::BOLD); + drawBatteryRightStable(renderer, + Rect{batteryIconX, rect.y + 14, RoundedRaffMetrics::values.batteryWidth, + RoundedRaffMetrics::values.batteryHeight}, + percentage, showBatteryPercentage); +} + +void RoundedRaffTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, + bool selected) const { + if (tabs.empty()) { + return; + } + + const int slotWidth = rect.width / static_cast(tabs.size()); + const int tabY = rect.y + 4; + const int tabHeight = rect.height - 12; + + for (size_t i = 0; i < tabs.size(); i++) { + const int slotX = rect.x + static_cast(i) * slotWidth; + const int tabX = slotX + 4; + const int tabWidth = slotWidth - 8; + const auto& tab = tabs[i]; + + if (tab.selected) { + renderer.fillRoundedRect(tabX, tabY, tabWidth, tabHeight, 18, selected ? Color::Black : Color::DarkGray); + } + + const int textWidth = renderer.getTextWidth(kTitleFontId, tab.label, EpdFontFamily::BOLD); + const int textX = slotX + (slotWidth - textWidth) / 2; + const int textY = tabY + (tabHeight - renderer.getLineHeight(kTitleFontId)) / 2; + renderer.drawText(kTitleFontId, textX, textY, tab.label, !(tab.selected), EpdFontFamily::BOLD); + } + + // Full-width divider between tabs and setting rows. + renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width, rect.y + rect.height - 1, true); +} + +void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, + bool& bufferRestored, std::function storeCoverBuffer) const { + const int tileWidth = rect.width - 2 * RoundedRaffMetrics::values.contentSidePadding; + const int tileHeight = rect.height; + const int tileY = rect.y; + const bool hasContinueReading = !recentBooks.empty(); + if (coverWidth == 0) { + coverWidth = RoundedRaffMetrics::values.homeCoverHeight * 0.6; + } + const int imgY = tileY + (tileHeight - RoundedRaffMetrics::values.homeCoverHeight) / 2; + const int tileX = RoundedRaffMetrics::values.contentSidePadding; + + // Draw book card regardless, fill with message based on `hasContinueReading` + // Draw cover image as background if available (inside the box) + // Only load from SD on first render, then use stored buffer + if (hasContinueReading) { + RecentBook book = recentBooks[0]; + if (!coverRendered) { + std::string coverPath = book.coverBmpPath; + bool hasCover = true; + if (coverPath.empty()) { + hasCover = false; + } else { + const std::string coverBmpPath = + UITheme::getCoverThumbPath(coverPath, RoundedRaffMetrics::values.homeCoverHeight); + + // First time: load cover from SD and render + FsFile file; + if (Storage.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + coverWidth = bitmap.getWidth(); + renderer.drawBitmap(bitmap, tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, + RoundedRaffMetrics::values.homeCoverHeight); + renderer.maskRoundedRectOutsideCorners(tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, + RoundedRaffMetrics::values.homeCoverHeight, kCoverRadius, + Color::LightGray); + } else { + hasCover = false; + } + file.close(); + } + } + + // Draw either way + renderer.drawRoundedRect(tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, + RoundedRaffMetrics::values.homeCoverHeight, 1, kCoverRadius, true); + + if (!hasCover) { + // Render empty cover + renderer.fillRect(tileX + (tileWidth - coverWidth) / 2, imgY + (RoundedRaffMetrics::values.homeCoverHeight / 3), + coverWidth, 2 * RoundedRaffMetrics::values.homeCoverHeight / 3, true); + renderer.drawIcon(CoverIcon, tileX + (tileWidth - coverWidth) / 2 + 24, imgY + 24, 32, 32); + renderer.maskRoundedRectOutsideCorners(tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, + RoundedRaffMetrics::values.homeCoverHeight, kCoverRadius, + Color::LightGray); + } + + coverBufferStored = storeCoverBuffer(); + coverRendered = coverBufferStored; // Only consider it rendered if we successfully stored the buffer + } + + renderer.fillRoundedRect(tileX, tileY, tileWidth, imgY - tileY, kRowRadius, true, true, false, false, + Color::LightGray); + renderer.fillRectDither(tileX, imgY, (tileWidth - coverWidth) / 2, RoundedRaffMetrics::values.homeCoverHeight, + Color::LightGray); + renderer.fillRectDither(tileX + (tileWidth + coverWidth) / 2, imgY, (tileWidth - coverWidth) / 2, + RoundedRaffMetrics::values.homeCoverHeight, Color::LightGray); + renderer.fillRoundedRect(tileX, imgY + RoundedRaffMetrics::values.homeCoverHeight, tileWidth, + tileHeight - (imgY - tileY + RoundedRaffMetrics::values.homeCoverHeight), kRowRadius, + false, false, true, true, Color::LightGray); + } else { + renderer.fillRoundedRect(tileX, tileY, tileWidth, tileHeight, kRowRadius, Color::LightGray); + renderer.drawCenteredText(kTitleFontId, rect.y + rect.height / 2 - renderer.getLineHeight(kTitleFontId) / 2, + tr(STR_NO_OPEN_BOOK)); + } +} + +void RoundedRaffTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, + const std::function& rowIcon) const { + (void)rowIcon; + const int sidePadding = RoundedRaffMetrics::values.contentSidePadding; + const int rowX = rect.x + sidePadding; + const int rowHeight = renderer.getLineHeight(kTitleFontId) + 20; // 10px top + 10px bottom + const int rowGap = kSelectableRowGap; + const int rowStep = rowHeight + rowGap; + const int pageItems = std::max(1, rect.height / rowStep); + const int safeSelectedIndex = std::max(0, selectedIndex); + const int pageStartIndex = (safeSelectedIndex / pageItems) * pageItems; + const int menuTop = rect.y; + const int textLineHeight = renderer.getLineHeight(kTitleFontId); + const int menuMaxWidth = std::max(0, rect.width - sidePadding * 2); + + for (int i = pageStartIndex; i < buttonCount && i < pageStartIndex + pageItems; ++i) { + const std::string label = buttonLabel(i); + const int rowY = menuTop + (i - pageStartIndex) * rowStep; + constexpr int kRowPaddingX = 40; // 20px L/R + const int maxLabelWidth = std::max(0, menuMaxWidth - kRowPaddingX); + const std::string truncatedLabel = + renderer.truncatedText(kTitleFontId, label.c_str(), maxLabelWidth, EpdFontFamily::BOLD); + const int rowWidth = std::min( + menuMaxWidth, renderer.getTextWidth(kTitleFontId, truncatedLabel.c_str(), EpdFontFamily::BOLD) + kRowPaddingX); + const bool isSelected = selectedIndex == i; + renderer.fillRoundedRect(rowX, rowY, rowWidth, rowHeight, kMenuRadius, isSelected ? Color::Black : Color::White); + const int textY = rowY + (rowHeight - textLineHeight) / 2; + const int textX = rowX + kInteractiveInsetX; + if (selectedIndex == i) { + renderer.drawText(kTitleFontId, textX, textY, truncatedLabel.c_str(), false, EpdFontFamily::BOLD); + } else { + renderer.drawText(kTitleFontId, textX, textY, truncatedLabel.c_str(), true, EpdFontFamily::BOLD); + } + } + + drawScrollBar(renderer, rect, buttonCount, pageStartIndex, pageItems); +} + +void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, + const std::function& rowSubtitle, + const std::function& rowIcon, + const std::function& rowValue, bool highlightValue) const { + (void)rowIcon; + (void)highlightValue; + const bool hasSubtitle = static_cast(rowSubtitle); + const int titleLineHeight = renderer.getLineHeight(kTitleFontId); + const int subtitleLineHeight = renderer.getLineHeight(kSubtitleFontId); + constexpr int subtitleTopPadding = 10; + constexpr int subtitleBottomPadding = 10; + constexpr int subtitleInterLineGap = 4; + const int subtitleRowHeight = + subtitleTopPadding + titleLineHeight + subtitleInterLineGap + subtitleLineHeight + subtitleBottomPadding; + const int rowHeight = hasSubtitle ? subtitleRowHeight : RoundedRaffMetrics::values.listRowHeight; + const int rowStep = rowHeight + kSelectableRowGap; + const int pageItems = std::max(1, rect.height / rowStep); + const int pageStartIndex = std::max(0, selectedIndex / pageItems) * pageItems; + + const int sidePadding = RoundedRaffMetrics::values.contentSidePadding; + const int rowX = rect.x + sidePadding; + const int rowWidth = rect.width - sidePadding * 2; + + for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { + const int rowY = rect.y + (i % pageItems) * rowStep; + const bool isSelected = i == selectedIndex; + renderer.fillRoundedRect(rowX, rowY, rowWidth, rowHeight, kRowRadius, isSelected ? Color::Black : Color::White); + + constexpr int kMinTitleWidth = 40; + constexpr int kMinValueGap = kInteractiveInsetX; + int textAreaWidth = rowWidth - kInteractiveInsetX * 2; + if (rowValue) { + std::string valueText = rowValue(i); + if (!valueText.empty()) { + const int maxValueWidth = std::max(0, rowWidth - kInteractiveInsetX * 2 - kMinValueGap - kMinTitleWidth); + if (maxValueWidth > 0) { + const std::string truncatedValue = + renderer.truncatedText(kTitleFontId, valueText.c_str(), maxValueWidth, EpdFontFamily::REGULAR); + const int valueW = renderer.getTextWidth(kTitleFontId, truncatedValue.c_str(), EpdFontFamily::REGULAR); + renderer.drawText(kTitleFontId, rowX + rowWidth - kInteractiveInsetX - valueW, + rowY + (rowHeight - renderer.getLineHeight(kTitleFontId)) / 2, truncatedValue.c_str(), + !isSelected, EpdFontFamily::REGULAR); + textAreaWidth = std::max(0, textAreaWidth - valueW - kMinValueGap); + } + } + } + + if (hasSubtitle) { + const std::string subtitleRaw = rowSubtitle(i); + auto title = renderer.truncatedText(kTitleFontId, rowTitle(i).c_str(), textAreaWidth, EpdFontFamily::BOLD); + + if (subtitleRaw.empty()) { + // If there is no subtitle/author, center title vertically in the full row. + const int centeredTitleY = rowY + (rowHeight - titleLineHeight) / 2; + renderer.drawText(kTitleFontId, rowX + kInteractiveInsetX, centeredTitleY, title.c_str(), !isSelected, + EpdFontFamily::BOLD); + } else { + const int titleY = rowY + subtitleTopPadding; + const int subtitleY = titleY + titleLineHeight + subtitleInterLineGap; + auto subtitle = + renderer.truncatedText(kSubtitleFontId, subtitleRaw.c_str(), textAreaWidth, EpdFontFamily::REGULAR); + renderer.drawText(kTitleFontId, rowX + kInteractiveInsetX, titleY, title.c_str(), !isSelected, + EpdFontFamily::BOLD); + renderer.drawText(kSubtitleFontId, rowX + kInteractiveInsetX, subtitleY, subtitle.c_str(), !isSelected, + EpdFontFamily::REGULAR); + } + } else { + auto title = renderer.truncatedText(kTitleFontId, rowTitle(i).c_str(), textAreaWidth, EpdFontFamily::BOLD); + renderer.drawText(kTitleFontId, rowX + kInteractiveInsetX, + rowY + (rowHeight - renderer.getLineHeight(kTitleFontId)) / 2, title.c_str(), !isSelected, + EpdFontFamily::BOLD); + } + } + + drawScrollBar(renderer, rect, itemCount, pageStartIndex, pageItems); +} + +void RoundedRaffTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) const { + const GfxRenderer::Orientation origOrientation = renderer.getOrientation(); + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + const int sidePadding = 20; + const int groupGap = 10; + const int bottomMargin = 10; + const int hintHeight = RoundedRaffMetrics::values.buttonHintsHeight - 10; // 30px total guide height + const int groupWidth = (pageWidth - sidePadding * 2 - groupGap) / 2; + const int hintY = pageHeight - hintHeight - bottomMargin; + const int textY = hintY + (hintHeight - renderer.getLineHeight(kGuideFontId)) / 2; + + const bool backDisabled = (btn1 == nullptr || btn1[0] == '\0'); + const int leftGroupX = sidePadding; + const int rightGroupX = leftGroupX + groupWidth + groupGap; + const std::string backLabel = backDisabled ? "" : sanitizeButtonLabel(std::string(btn1)); + // Callers should provide the button labels. If a label is not specified, it should render empty. + const std::string selectText = (btn2 && btn2[0] != '\0') ? sanitizeButtonLabel(std::string(btn2)) : ""; + const std::string upText = (btn3 && btn3[0] != '\0') ? sanitizeButtonLabel(std::string(btn3)) : ""; + const std::string downText = (btn4 && btn4[0] != '\0') ? sanitizeButtonLabel(std::string(btn4)) : ""; + + // Ensure button hints always "win" visually even if other elements accidentally render into this area. + renderer.fillRect(leftGroupX, hintY, groupWidth, hintHeight, false); + renderer.fillRect(rightGroupX, hintY, groupWidth, hintHeight, false); + + renderer.drawRoundedRect(leftGroupX, hintY, groupWidth, hintHeight, 2, kBottomRadius, true); + const int selectWidth = renderer.getTextWidth(kGuideFontId, selectText.c_str(), EpdFontFamily::REGULAR); + const int downWidth = renderer.getTextWidth(kGuideFontId, downText.c_str(), EpdFontFamily::REGULAR); + constexpr int innerEdgePadding = 16; + + const int backX = leftGroupX + innerEdgePadding; + const int selectX = leftGroupX + groupWidth - innerEdgePadding - selectWidth; + const int upX = rightGroupX + innerEdgePadding; + const int downX = rightGroupX + groupWidth - innerEdgePadding - downWidth; + + if (!backDisabled) { + renderer.drawText(kGuideFontId, backX, textY, backLabel.c_str(), true, EpdFontFamily::REGULAR); + } + renderer.drawText(kGuideFontId, selectX, textY, selectText.c_str(), true, EpdFontFamily::REGULAR); + + renderer.drawRoundedRect(rightGroupX, hintY, groupWidth, hintHeight, 2, kBottomRadius, true); + + renderer.drawText(kGuideFontId, upX, textY, upText.c_str(), true, EpdFontFamily::REGULAR); + renderer.drawText(kGuideFontId, downX, textY, downText.c_str(), true, EpdFontFamily::REGULAR); + + renderer.setOrientation(origOrientation); +} diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.h b/src/components/themes/roundedraff/RoundedRaffTheme.h new file mode 100644 index 0000000000..7131aa8dc3 --- /dev/null +++ b/src/components/themes/roundedraff/RoundedRaffTheme.h @@ -0,0 +1,64 @@ +#pragma once + +#include "components/themes/BaseTheme.h" + +class GfxRenderer; + +namespace RoundedRaffMetrics { +constexpr ThemeMetrics values = {.batteryWidth = 15, + .batteryHeight = 12, + .topPadding = 0, + .batteryBarHeight = 20, + .headerHeight = 45, + .verticalSpacing = 10, + .contentSidePadding = 20, + .listRowHeight = 42, + .listWithSubtitleRowHeight = 69, + .menuRowHeight = 42, + .menuSpacing = 6, + .tabSpacing = 10, + .tabBarHeight = 50, + .scrollBarWidth = 4, + .scrollBarRightOffset = 5, + .homeTopPadding = 55, + // Smaller cover tile so the home menu sits higher (fits 5 items without overlap). + .homeCoverHeight = 300, + .homeCoverTileHeight = 350, + .homeRecentBooksCount = 1, + .homeContinueReadingInMenu = true, + .homeMenuTopOffset = 20, + .buttonHintsHeight = 40, + .sideButtonHintsWidth = 30, + .progressBarHeight = 16, + .progressBarMarginTop = 1, + .statusBarHorizontalMargin = 5, + .statusBarVerticalMargin = 19, + .keyboardKeyWidth = 22, + .keyboardKeyHeight = 30, + .keyboardKeySpacing = 10, + .keyboardBottomAligned = false, + .keyboardCenteredText = false}; +} + +class RoundedRaffTheme : public BaseTheme { + public: + void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, + const char* subtitle = nullptr) const override; + void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, + bool selected) const override; + void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, + std::function storeCoverBuffer) const override; + void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, + const std::function& rowIcon) const override; + void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, + const std::function& rowSubtitle = nullptr, + const std::function& rowIcon = nullptr, + const std::function& rowValue = nullptr, + bool highlightValue = false) const override; + void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) const override; + bool homeMenuShowsContinueReading() const { return true; } +}; From ef98d44ef3fa3bf09db1b591b49d06d45f522a73 Mon Sep 17 00:00:00 2001 From: Stefan Blixten Karlsson Date: Mon, 27 Apr 2026 18:28:53 +0200 Subject: [PATCH 08/21] fix: python requirements files (#1768) ## Summary The Python scripts in the current repo have many requirements that are not mentioned in any requirements.txt files, so I have therefore added them to the directories that need them. Question: Should these changes maybe moved into a "root" requirements.txt file? --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- lib/EpdFont/scripts/requirements.txt | 3 ++- scripts/requirements.txt | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 scripts/requirements.txt diff --git a/lib/EpdFont/scripts/requirements.txt b/lib/EpdFont/scripts/requirements.txt index d9ab1dc036..4b81c657ad 100644 --- a/lib/EpdFont/scripts/requirements.txt +++ b/lib/EpdFont/scripts/requirements.txt @@ -1 +1,2 @@ -freetype-py==2.5.1 +fonttools>=4.62.1 +freetype-py>=2.5.1 diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000000..79692e9dbe --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,5 @@ +pillow>=12.2.0 +cairosvg>=2.9.0 +matplotlib>=3.10.9 +pyserial>=3.5 +colorama>=0.4.6 From 741dd89ac152e270ad626eb6834c76114d17a96c Mon Sep 17 00:00:00 2001 From: Rob Hooper Date: Mon, 27 Apr 2026 12:37:41 -0400 Subject: [PATCH 09/21] fix: cap per-side horizontal CSS inset at 2em (#1694) ## Summary - **What is the goal of this PR?** Fix chapter-opener text collapsing to 1-2 words per line in EPUBs that apply large em-based horizontal CSS insets. - **What changes are included?** Caps `marginLeft`, `marginRight`, `paddingLeft`, and `paddingRight` at 2em in `BlockStyle::fromCssStyle` (`lib/Epub/Epub/blocks/BlockStyle.h`). Vertical margins/padding are unchanged - the bug is horizontal-only. ## Additional Context **Repro:** *Mother Night* by Kurt Vonnegut, Chapter 21 ("My best friend..."). The chapter-opening element's embedded CSS sets a large horizontal inset. - Settings: Embedded Style = On, Justify alignment (default). - Before: 1-2 words per line with a visible river between them. - After: body text fills the usable page width like every other paragraph. **Why the clamp lives in `fromCssStyle`:** This is the single point where CSS lengths resolve to pixels, so clamping here keeps both `effectiveWidth` call sites in `ChapterHtmlSlimParser` (`:1131-1133` makePages, `:841-844` long-block split) consistent with the `leftInset()` xOffset. A width-only clamp at either call site would leave text pushed right by a large xOffset. **Test plan:** - [x] Flashed to Xteink X4. Chapter 21 of *Mother Night* renders normally with Embedded Style on. - Users with cached layouts of affected books need to delete `.crosspoint/epub_/sections/` (or the whole `.crosspoint/`) on the SD card to pick up the fix. **Before / After:**

IMG_0320 IMG_0324

--- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< YES >**_ I used Claude Code (Opus 4.7) to evaluate the codebase and find the relevant code related to this rendering. From there, it was human designed, reviewed and tested. Co-authored-by: rhoopr <> --- lib/Epub/Epub/blocks/BlockStyle.h | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/Epub/Epub/blocks/BlockStyle.h b/lib/Epub/Epub/blocks/BlockStyle.h index a5a616bf90..4166fceb65 100644 --- a/lib/Epub/Epub/blocks/BlockStyle.h +++ b/lib/Epub/Epub/blocks/BlockStyle.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "Epub/css/CssStyle.h" @@ -8,6 +9,12 @@ * BlockStyle - Block-level styling properties */ struct BlockStyle { + // Upper bound (in em) for any single side's horizontal margin or padding. + // Some EPUBs apply huge em-based insets to chapter-opener classes; without a + // cap, effectiveWidth collapses to 1-2 words per line and justification dumps + // the remaining space into a single gap. + static constexpr float MAX_HORIZONTAL_INSET_EM = 2.0f; + CssTextAlign alignment = CssTextAlign::Justify; // Spacing (in pixels) @@ -68,16 +75,17 @@ struct BlockStyle { const uint16_t viewportWidth = 0) { BlockStyle blockStyle; const float vw = viewportWidth; + const auto maxHorizontalInsetPx = static_cast(emSize * MAX_HORIZONTAL_INSET_EM); // Resolve all CssLength values to pixels using the current font's em size and viewport width blockStyle.marginTop = cssStyle.marginTop.toPixelsInt16(emSize, vw); blockStyle.marginBottom = cssStyle.marginBottom.toPixelsInt16(emSize, vw); - blockStyle.marginLeft = cssStyle.marginLeft.toPixelsInt16(emSize, vw); - blockStyle.marginRight = cssStyle.marginRight.toPixelsInt16(emSize, vw); + blockStyle.marginLeft = std::min(cssStyle.marginLeft.toPixelsInt16(emSize, vw), maxHorizontalInsetPx); + blockStyle.marginRight = std::min(cssStyle.marginRight.toPixelsInt16(emSize, vw), maxHorizontalInsetPx); blockStyle.paddingTop = cssStyle.paddingTop.toPixelsInt16(emSize, vw); blockStyle.paddingBottom = cssStyle.paddingBottom.toPixelsInt16(emSize, vw); - blockStyle.paddingLeft = cssStyle.paddingLeft.toPixelsInt16(emSize, vw); - blockStyle.paddingRight = cssStyle.paddingRight.toPixelsInt16(emSize, vw); + blockStyle.paddingLeft = std::min(cssStyle.paddingLeft.toPixelsInt16(emSize, vw), maxHorizontalInsetPx); + blockStyle.paddingRight = std::min(cssStyle.paddingRight.toPixelsInt16(emSize, vw), maxHorizontalInsetPx); // For textIndent: if it's a percentage we can't resolve (no viewport width), // leave textIndentDefined=false so the EmSpace fallback in applyParagraphIndent() is used From 59c8d394aae5d1c6631a13c476326b7bc5b6c94d Mon Sep 17 00:00:00 2001 From: Pavl Zubenko Date: Tue, 28 Apr 2026 17:37:45 +0300 Subject: [PATCH 10/21] feat: enhance logging for mutex operations in HalStorage and ActivityManager --- lib/hal/HalStorage.cpp | 15 ++++++-- src/activities/ActivityManager.cpp | 12 +++++++ .../reader/ClipSelectionActivity.cpp | 36 ++++++++----------- src/activities/reader/EpubReaderActivity.cpp | 8 +++-- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/lib/hal/HalStorage.cpp b/lib/hal/HalStorage.cpp index 24395e4fa8..60ae18390a 100644 --- a/lib/hal/HalStorage.cpp +++ b/lib/hal/HalStorage.cpp @@ -4,6 +4,7 @@ #include // need to be included before SdFat.h for compatibility with FS.h's File class #include #include +#include #include @@ -26,8 +27,18 @@ bool HalStorage::ready() const { return SDCard.ready(); } class HalStorage::StorageLock { public: - StorageLock() { xSemaphoreTake(HalStorage::getInstance().storageMutex, portMAX_DELAY); } - ~StorageLock() { xSemaphoreGive(HalStorage::getInstance().storageMutex); } + StorageLock() { +#if LOG_LEVEL >= 2 + LOG_DBG("LOCK", "SL take from %s", pcTaskGetName(nullptr)); +#endif + xSemaphoreTake(HalStorage::getInstance().storageMutex, portMAX_DELAY); + } + ~StorageLock() { +#if LOG_LEVEL >= 2 + LOG_DBG("LOCK", "SL give from %s", pcTaskGetName(nullptr)); +#endif + xSemaphoreGive(HalStorage::getInstance().storageMutex); + } }; #define HAL_STORAGE_WRAPPED_CALL(method, ...) \ diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 915f956e14..702b995a8e 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -278,17 +278,26 @@ void ActivityManager::requestUpdateAndWait() { // RenderLock RenderLock::RenderLock() { +#if LOG_LEVEL >= 2 + LOG_DBG("LOCK", "RL take from %s", pcTaskGetName(nullptr)); +#endif xSemaphoreTake(activityManager.renderingMutex, portMAX_DELAY); isLocked = true; } RenderLock::RenderLock([[maybe_unused]] Activity&) { +#if LOG_LEVEL >= 2 + LOG_DBG("LOCK", "RL take from %s", pcTaskGetName(nullptr)); +#endif xSemaphoreTake(activityManager.renderingMutex, portMAX_DELAY); isLocked = true; } RenderLock::~RenderLock() { if (isLocked) { +#if LOG_LEVEL >= 2 + LOG_DBG("LOCK", "RL give from %s", pcTaskGetName(nullptr)); +#endif xSemaphoreGive(activityManager.renderingMutex); isLocked = false; } @@ -296,6 +305,9 @@ RenderLock::~RenderLock() { void RenderLock::unlock() { if (isLocked) { +#if LOG_LEVEL >= 2 + LOG_DBG("LOCK", "RL unlock from %s", pcTaskGetName(nullptr)); +#endif xSemaphoreGive(activityManager.renderingMutex); isLocked = false; } diff --git a/src/activities/reader/ClipSelectionActivity.cpp b/src/activities/reader/ClipSelectionActivity.cpp index 25a87d7449..3ee8b3eeef 100644 --- a/src/activities/reader/ClipSelectionActivity.cpp +++ b/src/activities/reader/ClipSelectionActivity.cpp @@ -69,29 +69,35 @@ void ClipSelectionActivity::loop() { const int total = static_cast(words.size()); buttonNavigator.onNextRelease([this, total] { + if (cursorIdx + 1 >= total) return; const int prevPage = words[cursorIdx].pageIdx; - cursorIdx = ButtonNavigator::nextIndex(cursorIdx, total); + cursorIdx = cursorIdx + 1; if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; requestUpdate(); }); buttonNavigator.onNextContinuous([this] { const int prevPage = words[cursorIdx].pageIdx; - cursorIdx = lineEndForward(cursorIdx); + const int next = lineEndForward(cursorIdx); + if (next == cursorIdx) return; + cursorIdx = next; if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; requestUpdate(); }); - buttonNavigator.onPreviousRelease([this, total] { + buttonNavigator.onPreviousRelease([this] { + if (cursorIdx == 0) return; const int prevPage = words[cursorIdx].pageIdx; - cursorIdx = ButtonNavigator::previousIndex(cursorIdx, total); + cursorIdx = cursorIdx - 1; if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; requestUpdate(); }); buttonNavigator.onPreviousContinuous([this] { const int prevPage = words[cursorIdx].pageIdx; - cursorIdx = lineEndBackward(cursorIdx); + const int prev = lineEndBackward(cursorIdx); + if (prev == cursorIdx) return; + cursorIdx = prev; if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; requestUpdate(); }); @@ -203,15 +209,9 @@ int ClipSelectionActivity::lineEndForward(int idx) const { last = i; } - // Already at line end — jump to end of next line + // Already at line end — jump to first word of next line if (last == idx && idx + 1 < total) { - const int nextY = words[idx + 1].y; - const int nextPage = words[idx + 1].pageIdx; - last = idx + 1; - for (int i = idx + 2; i < total; ++i) { - if (words[i].pageIdx != nextPage || words[i].y != nextY) break; - last = i; - } + return idx + 1; } return last; @@ -228,15 +228,9 @@ int ClipSelectionActivity::lineEndBackward(int idx) const { first = i; } - // Already at line start — jump to start of previous line + // Already at line start — jump to last word of previous line if (first == idx && idx - 1 >= 0) { - const int prevY = words[idx - 1].y; - const int prevPage = words[idx - 1].pageIdx; - first = idx - 1; - for (int i = idx - 2; i >= 0; --i) { - if (words[i].pageIdx != prevPage || words[i].y != prevY) break; - first = i; - } + return idx - 1; } return first; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index ab3c72839a..4caf8710c6 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include @@ -432,9 +433,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction for (int i = 0; i < static_cast(wlist.size()); ++i) { const int wx = mLeft + line.xPos + xpos[i]; const int wy = mTop + line.yPos; - const int ww = (i + 1 < static_cast(xpos.size())) - ? static_cast(xpos[i + 1]) - static_cast(xpos[i]) - : renderer.getTextWidth(readerFontId, wlist[i].c_str()); + const int ww = renderer.getTextWidth(readerFontId, wlist[i].c_str()); if (ww > 0) { words.push_back({wx, wy, ww, lineH, pi, wlist[i]}); } @@ -747,6 +746,9 @@ void EpubReaderActivity::render(RenderLock&& lock) { } silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight); saveProgress(currentSpineIndex, section->currentPage, section->pageCount); +#if LOG_LEVEL >= 2 + LOG_DBG("ERS", "render stack hwm=%u", uxTaskGetStackHighWaterMark(nullptr)); +#endif if (pendingScreenshot) { pendingScreenshot = false; From 0a211b59f454ec2ca55ff6ea62b445cd63724ea6 Mon Sep 17 00:00:00 2001 From: Pavl Zubenko Date: Tue, 28 Apr 2026 17:55:15 +0300 Subject: [PATCH 11/21] fix: update clipping format to match Kindle-compatible standards --- src/clippings/ClippingsManager.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/clippings/ClippingsManager.cpp b/src/clippings/ClippingsManager.cpp index 77b4349fe1..aa018cc334 100644 --- a/src/clippings/ClippingsManager.cpp +++ b/src/clippings/ClippingsManager.cpp @@ -12,24 +12,24 @@ bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::str return false; } - // Header line: "Title / Author" + // Header line: "Title (Author)" — Kindle-compatible format char header[128]; - snprintf(header, sizeof(header), "%s / %s\n", bookTitle.c_str(), author.c_str()); + snprintf(header, sizeof(header), "%s (%s)\n", bookTitle.c_str(), author.c_str()); - // Location line: "Chapter: X | Page N" + // Location line: "- Highlight on Page N | Chapter X" — Kindle-compatible format char location[128]; if (!chapterTitle.empty()) { - snprintf(location, sizeof(location), "Chapter: %s | Page %d\n", chapterTitle.c_str(), pageNumber); + snprintf(location, sizeof(location), "- Highlight on Page %d | %s\n", pageNumber, chapterTitle.c_str()); } else { - snprintf(location, sizeof(location), "Page %d\n", pageNumber); + snprintf(location, sizeof(location), "- Highlight on Page %d\n", pageNumber); } - // Body: quoted text, trimmed to 2000 chars to avoid writing huge clippings + // Body: text trimmed to 2000 chars to avoid writing huge clippings static constexpr size_t MAX_TEXT = 2000; const size_t textLen = selectedText.size() < MAX_TEXT ? selectedText.size() : MAX_TEXT; - static constexpr char quote[] = "\n\""; - static constexpr char separator[] = "\"\n\n==========\n\n"; + static constexpr char quote[] = "\n"; + static constexpr char separator[] = "\n==========\n"; const size_t headerLen = strlen(header); const size_t locationLen = strlen(location); From bd279adf57a334bf046df14b2dfdc08e89c59eb7 Mon Sep 17 00:00:00 2001 From: Pavl Zubenko Date: Tue, 28 Apr 2026 18:15:04 +0300 Subject: [PATCH 12/21] fix: improve error logging and revert page on load failure in ClipSelectionActivity --- src/activities/reader/ClipSelectionActivity.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/ClipSelectionActivity.cpp b/src/activities/reader/ClipSelectionActivity.cpp index 3ee8b3eeef..139242ff57 100644 --- a/src/activities/reader/ClipSelectionActivity.cpp +++ b/src/activities/reader/ClipSelectionActivity.cpp @@ -154,10 +154,13 @@ void ClipSelectionActivity::render(RenderLock&&) { } void ClipSelectionActivity::switchToPage(int pageIdx) { + const int oldPage = section.currentPage; section.currentPage = startPageInSection + pageIdx; auto page = section.loadPageFromSectionFile(); if (!page) { - LOG_ERR("CLIP", "Failed to load page %d for display", pageIdx); + section.currentPage = oldPage; + LOG_ERR("CLIP", "Failed to load page %d (section.currentPage=%d, currentDisplayPage=%d) — reverted", + pageIdx, section.currentPage, currentDisplayPage); return; } From 281752c0f216affc64230067741ccf4599fd0afb Mon Sep 17 00:00:00 2001 From: Pavl Zubenko Date: Tue, 28 Apr 2026 18:20:11 +0300 Subject: [PATCH 13/21] fix: use canonical Kindle 'Your Highlight' phrasing and apply clang-format --- src/activities/reader/ClipSelectionActivity.cpp | 4 ++-- src/clippings/ClippingsManager.cpp | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/activities/reader/ClipSelectionActivity.cpp b/src/activities/reader/ClipSelectionActivity.cpp index 139242ff57..414e5756af 100644 --- a/src/activities/reader/ClipSelectionActivity.cpp +++ b/src/activities/reader/ClipSelectionActivity.cpp @@ -159,8 +159,8 @@ void ClipSelectionActivity::switchToPage(int pageIdx) { auto page = section.loadPageFromSectionFile(); if (!page) { section.currentPage = oldPage; - LOG_ERR("CLIP", "Failed to load page %d (section.currentPage=%d, currentDisplayPage=%d) — reverted", - pageIdx, section.currentPage, currentDisplayPage); + LOG_ERR("CLIP", "Failed to load page %d (section.currentPage=%d, currentDisplayPage=%d) — reverted", pageIdx, + section.currentPage, currentDisplayPage); return; } diff --git a/src/clippings/ClippingsManager.cpp b/src/clippings/ClippingsManager.cpp index aa018cc334..ffe2d31b89 100644 --- a/src/clippings/ClippingsManager.cpp +++ b/src/clippings/ClippingsManager.cpp @@ -16,12 +16,12 @@ bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::str char header[128]; snprintf(header, sizeof(header), "%s (%s)\n", bookTitle.c_str(), author.c_str()); - // Location line: "- Highlight on Page N | Chapter X" — Kindle-compatible format + // Location line: "- Your Highlight on Page N | Chapter X" — Kindle-compatible format char location[128]; if (!chapterTitle.empty()) { - snprintf(location, sizeof(location), "- Highlight on Page %d | %s\n", pageNumber, chapterTitle.c_str()); + snprintf(location, sizeof(location), "- Your Highlight on Page %d | %s\n", pageNumber, chapterTitle.c_str()); } else { - snprintf(location, sizeof(location), "- Highlight on Page %d\n", pageNumber); + snprintf(location, sizeof(location), "- Your Highlight on Page %d\n", pageNumber); } // Body: text trimmed to 2000 chars to avoid writing huge clippings From 92346caf06617e9cec6092e5088b9200706e10d0 Mon Sep 17 00:00:00 2001 From: Pavlo Zubenko Date: Tue, 28 Apr 2026 18:25:43 +0300 Subject: [PATCH 14/21] fix: use std::string for header/location and single-buffer write in ClippingsManager --- src/clippings/ClippingsManager.cpp | 46 +++++++++++++++++------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/clippings/ClippingsManager.cpp b/src/clippings/ClippingsManager.cpp index ffe2d31b89..b9aeb414a7 100644 --- a/src/clippings/ClippingsManager.cpp +++ b/src/clippings/ClippingsManager.cpp @@ -12,35 +12,41 @@ bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::str return false; } - // Header line: "Title (Author)" — Kindle-compatible format - char header[128]; - snprintf(header, sizeof(header), "%s (%s)\n", bookTitle.c_str(), author.c_str()); - - // Location line: "- Your Highlight on Page N | Chapter X" — Kindle-compatible format - char location[128]; + // Build header and location as strings to avoid truncation of long titles/authors + const std::string header = bookTitle + " (" + author + ")\n"; + std::string location = "- Your Highlight on Page " + std::to_string(pageNumber); if (!chapterTitle.empty()) { - snprintf(location, sizeof(location), "- Your Highlight on Page %d | %s\n", pageNumber, chapterTitle.c_str()); - } else { - snprintf(location, sizeof(location), "- Your Highlight on Page %d\n", pageNumber); + location += " | " + chapterTitle; } + location += "\n"; - // Body: text trimmed to 2000 chars to avoid writing huge clippings static constexpr size_t MAX_TEXT = 2000; const size_t textLen = selectedText.size() < MAX_TEXT ? selectedText.size() : MAX_TEXT; - static constexpr char quote[] = "\n"; + // Concatenate into a single buffer and perform one write to avoid partial records on SD failure static constexpr char separator[] = "\n==========\n"; + static constexpr size_t separatorLen = sizeof(separator) - 1; + const size_t totalLen = header.size() + location.size() + 1 + textLen + separatorLen; - const size_t headerLen = strlen(header); - const size_t locationLen = strlen(location); - const size_t quoteLen = strlen(quote); - const size_t separatorLen = strlen(separator); + auto* buf = static_cast(malloc(totalLen)); + if (!buf) { + LOG_ERR("CLIP", "malloc failed: %zu bytes", totalLen); + file.close(); + return false; + } - bool ok = file.write(header, headerLen) == headerLen; - ok = ok && file.write(location, locationLen) == locationLen; - ok = ok && file.write(quote, quoteLen) == quoteLen; - ok = ok && file.write(selectedText.c_str(), textLen) == textLen; - ok = ok && file.write(separator, separatorLen) == separatorLen; + size_t pos = 0; + memcpy(buf + pos, header.c_str(), header.size()); + pos += header.size(); + memcpy(buf + pos, location.c_str(), location.size()); + pos += location.size(); + buf[pos++] = '\n'; + memcpy(buf + pos, selectedText.c_str(), textLen); + pos += textLen; + memcpy(buf + pos, separator, separatorLen); + + const bool ok = file.write(buf, totalLen) == totalLen; + free(buf); file.flush(); file.close(); From 88ad6499336df1392bee095ffe107de53490817d Mon Sep 17 00:00:00 2001 From: Louieza23 Date: Tue, 28 Apr 2026 12:48:08 -0700 Subject: [PATCH 15/21] feat: add save clipping power-button action --- src/CrossPointSettings.h | 2 ++ src/SettingsList.h | 8 +++++--- src/activities/reader/EpubReaderActivity.cpp | 9 +++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index bd4de5914e..0bb1fadc01 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -149,6 +149,7 @@ class CrossPointSettings { READING_STATS = 10, SCREENSHOT = 11, CYCLE_PAGE_TURN = 12, + SAVE_CLIPPING = 13, SHORT_PWRBTN_COUNT }; @@ -175,6 +176,7 @@ class CrossPointSettings { LONG_MENU_READING_STATS = 9, LONG_MENU_SCREENSHOT = 10, LONG_MENU_CYCLE_PAGE_TURN = 11, + LONG_MENU_SAVE_CLIPPING = 12, LONG_PRESS_MENU_ACTION_COUNT }; diff --git a/src/SettingsList.h b/src/SettingsList.h index 5a5af6221c..b2e7bcc29d 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -99,19 +99,21 @@ inline const std::vector& getSettingsList() { {StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH, StrId::STR_CHANGE_FONT, StrId::STR_TOGGLE_GUIDE_DOTS, StrId::STR_TOGGLE_BIONIC_READING, StrId::STR_TOGGLE_BOOKMARK, StrId::STR_SYNC_PROGRESS, StrId::STR_MARK_FINISHED, - StrId::STR_READING_STATS, StrId::STR_SCREENSHOT_BUTTON, StrId::STR_CYCLE_PAGE_TURN}, + StrId::STR_READING_STATS, StrId::STR_SCREENSHOT_BUTTON, StrId::STR_CYCLE_PAGE_TURN, + StrId::STR_SAVE_CLIPPING}, "shortPwrBtn", StrId::STR_CAT_CONTROLS), SettingInfo::Enum(StrId::STR_LONG_PRESS_ACTION, &CrossPointSettings::longPwrBtn, {StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH, StrId::STR_CHANGE_FONT, StrId::STR_TOGGLE_GUIDE_DOTS, StrId::STR_TOGGLE_BIONIC_READING, StrId::STR_TOGGLE_BOOKMARK, StrId::STR_SYNC_PROGRESS, StrId::STR_MARK_FINISHED, - StrId::STR_READING_STATS, StrId::STR_SCREENSHOT_BUTTON, StrId::STR_CYCLE_PAGE_TURN}, + StrId::STR_READING_STATS, StrId::STR_SCREENSHOT_BUTTON, StrId::STR_CYCLE_PAGE_TURN, + StrId::STR_SAVE_CLIPPING}, "longPwrBtn", StrId::STR_CAT_CONTROLS), SettingInfo::Enum(StrId::STR_LONG_PRESS_MENU_ACTION, &CrossPointSettings::longPressMenuAction, {StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_CHANGE_FONT, StrId::STR_TOGGLE_GUIDE_DOTS, StrId::STR_TOGGLE_BIONIC_READING, StrId::STR_TOGGLE_BOOKMARK, StrId::STR_FORCE_REFRESH, StrId::STR_SYNC_PROGRESS, StrId::STR_MARK_FINISHED, StrId::STR_READING_STATS, - StrId::STR_SCREENSHOT_BUTTON, StrId::STR_CYCLE_PAGE_TURN}, + StrId::STR_SCREENSHOT_BUTTON, StrId::STR_CYCLE_PAGE_TURN, StrId::STR_SAVE_CLIPPING}, "longPressMenuAction", StrId::STR_CAT_CONTROLS), // --- System --- diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index e48505655e..41e2b3cb51 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -924,6 +924,9 @@ void EpubReaderActivity::executeReaderQuickAction(CrossPointSettings::LONG_PRESS case CrossPointSettings::LONG_MENU_SCREENSHOT: onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::SCREENSHOT); break; + case CrossPointSettings::LONG_MENU_SAVE_CLIPPING: + onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction::SAVE_CLIPPING); + break; case CrossPointSettings::LONG_MENU_CYCLE_PAGE_TURN: // Cycle Off->5s->10s->15s->20s->30s->45s->60s->Off (indices: 0,7,6,5,4,3,2,1) if (currentPageTurnOption == 0) { @@ -974,6 +977,9 @@ bool EpubReaderActivity::executeShortPowerButtonAction() { case CrossPointSettings::SHORT_PWRBTN::CYCLE_PAGE_TURN: executeReaderQuickAction(CrossPointSettings::LONG_MENU_CYCLE_PAGE_TURN); return true; + case CrossPointSettings::SHORT_PWRBTN::SAVE_CLIPPING: + executeReaderQuickAction(CrossPointSettings::LONG_MENU_SAVE_CLIPPING); + return true; default: return false; } @@ -1013,6 +1019,9 @@ bool EpubReaderActivity::executeLongPowerButtonAction() { case CrossPointSettings::SHORT_PWRBTN::CYCLE_PAGE_TURN: executeReaderQuickAction(CrossPointSettings::LONG_MENU_CYCLE_PAGE_TURN); return true; + case CrossPointSettings::SHORT_PWRBTN::SAVE_CLIPPING: + executeReaderQuickAction(CrossPointSettings::LONG_MENU_SAVE_CLIPPING); + return true; default: return false; } From 846a6200478f140e5104fb7c42cf857f0ccc4e9a Mon Sep 17 00:00:00 2001 From: Louieza23 Date: Tue, 28 Apr 2026 12:59:04 -0700 Subject: [PATCH 16/21] feat: improve clipping selector controls --- .../reader/ClipSelectionActivity.cpp | 123 +++++++++++++++--- src/activities/reader/ClipSelectionActivity.h | 2 + src/activities/reader/EpubReaderActivity.cpp | 10 +- src/activities/reader/EpubReaderActivity.h | 1 + 4 files changed, 114 insertions(+), 22 deletions(-) diff --git a/src/activities/reader/ClipSelectionActivity.cpp b/src/activities/reader/ClipSelectionActivity.cpp index 414e5756af..716164fe18 100644 --- a/src/activities/reader/ClipSelectionActivity.cpp +++ b/src/activities/reader/ClipSelectionActivity.cpp @@ -68,38 +68,38 @@ void ClipSelectionActivity::onExit() { void ClipSelectionActivity::loop() { const int total = static_cast(words.size()); - buttonNavigator.onNextRelease([this, total] { + buttonNavigator.onRelease({MappedInputManager::Button::Right}, [this, total] { if (cursorIdx + 1 >= total) return; - const int prevPage = words[cursorIdx].pageIdx; - cursorIdx = cursorIdx + 1; - if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; - requestUpdate(); + moveCursorToIndex(cursorIdx + 1); }); - buttonNavigator.onNextContinuous([this] { - const int prevPage = words[cursorIdx].pageIdx; + buttonNavigator.onContinuous({MappedInputManager::Button::Right}, [this] { const int next = lineEndForward(cursorIdx); if (next == cursorIdx) return; - cursorIdx = next; - if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; - requestUpdate(); + moveCursorToIndex(next); }); - buttonNavigator.onPreviousRelease([this] { + buttonNavigator.onRelease({MappedInputManager::Button::Left}, [this] { if (cursorIdx == 0) return; - const int prevPage = words[cursorIdx].pageIdx; - cursorIdx = cursorIdx - 1; - if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; - requestUpdate(); + moveCursorToIndex(cursorIdx - 1); }); - buttonNavigator.onPreviousContinuous([this] { - const int prevPage = words[cursorIdx].pageIdx; + buttonNavigator.onContinuous({MappedInputManager::Button::Left}, [this] { const int prev = lineEndBackward(cursorIdx); if (prev == cursorIdx) return; - cursorIdx = prev; - if (words[cursorIdx].pageIdx != prevPage) needsPageSwitch = true; - requestUpdate(); + moveCursorToIndex(prev); + }); + + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { + const int nextLine = adjacentLineIndex(cursorIdx, 1); + if (nextLine == cursorIdx) return; + moveCursorToIndex(nextLine); + }); + + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { + const int prevLine = adjacentLineIndex(cursorIdx, -1); + if (prevLine == cursorIdx) return; + moveCursorToIndex(prevLine); }); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { @@ -238,3 +238,86 @@ int ClipSelectionActivity::lineEndBackward(int idx) const { return first; } + +int ClipSelectionActivity::adjacentLineIndex(int idx, int direction) const { + if (direction == 0 || idx < 0 || idx >= static_cast(words.size())) { + return idx; + } + + const auto& current = words[idx]; + const int currentPage = current.pageIdx; + const int currentY = current.y; + const int targetX = current.x + current.w / 2; + const int total = static_cast(words.size()); + + int candidateStart = -1; + int candidateEnd = -1; + int candidatePage = currentPage; + int candidateY = currentY; + + if (direction > 0) { + for (int i = idx + 1; i < total; ++i) { + if (words[i].pageIdx == currentPage && words[i].y == currentY) { + continue; + } + candidateStart = i; + candidatePage = words[i].pageIdx; + candidateY = words[i].y; + candidateEnd = i; + for (int j = i + 1; j < total; ++j) { + if (words[j].pageIdx != candidatePage || words[j].y != candidateY) { + break; + } + candidateEnd = j; + } + break; + } + } else { + for (int i = idx - 1; i >= 0; --i) { + if (words[i].pageIdx == currentPage && words[i].y == currentY) { + continue; + } + candidateEnd = i; + candidatePage = words[i].pageIdx; + candidateY = words[i].y; + candidateStart = i; + for (int j = i - 1; j >= 0; --j) { + if (words[j].pageIdx != candidatePage || words[j].y != candidateY) { + break; + } + candidateStart = j; + } + break; + } + } + + if (candidateStart == -1 || candidateEnd == -1) { + return idx; + } + + int bestIdx = candidateStart; + int bestDistance = std::abs((words[candidateStart].x + words[candidateStart].w / 2) - targetX); + for (int i = candidateStart + 1; i <= candidateEnd; ++i) { + const int wordCenter = words[i].x + words[i].w / 2; + const int distance = std::abs(wordCenter - targetX); + if (distance < bestDistance) { + bestDistance = distance; + bestIdx = i; + } + } + + return bestIdx; +} + +void ClipSelectionActivity::moveCursorToIndex(int idx) { + if (idx < 0 || idx >= static_cast(words.size()) || idx == cursorIdx) { + return; + } + + const int prevPage = words[cursorIdx].pageIdx; + cursorIdx = idx; + if (words[cursorIdx].pageIdx != prevPage) { + needsPageSwitch = true; + } + requestUpdate(); +} diff --git a/src/activities/reader/ClipSelectionActivity.h b/src/activities/reader/ClipSelectionActivity.h index 4b4b63e4ab..f0f1a985ab 100644 --- a/src/activities/reader/ClipSelectionActivity.h +++ b/src/activities/reader/ClipSelectionActivity.h @@ -61,4 +61,6 @@ class ClipSelectionActivity final : public Activity { void drawHighlights(); int lineEndForward(int idx) const; int lineEndBackward(int idx) const; + int adjacentLineIndex(int idx, int direction) const; + void moveCursorToIndex(int idx); }; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 41e2b3cb51..c461981ec3 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -305,6 +305,10 @@ void EpubReaderActivity::loop() { return; } + if (!mappedInput.isPressed(MappedInputManager::Button::Power)) { + longPowerActionHandled = false; + } + if (completionPromptQueued) { completionPromptQueued = false; completionPromptShown = true; @@ -986,11 +990,13 @@ bool EpubReaderActivity::executeShortPowerButtonAction() { } bool EpubReaderActivity::executeLongPowerButtonAction() { - if (!mappedInput.wasReleased(MappedInputManager::Button::Power) || - mappedInput.getHeldTime() < SETTINGS.getPowerButtonLongPressDuration()) { + if (!mappedInput.isPressed(MappedInputManager::Button::Power) || + mappedInput.getHeldTime() < SETTINGS.getPowerButtonLongPressDuration() || longPowerActionHandled) { return false; } + longPowerActionHandled = true; + switch (SETTINGS.longPwrBtn) { case CrossPointSettings::SHORT_PWRBTN::TOGGLE_FONT: executeReaderQuickAction(CrossPointSettings::LONG_MENU_CHANGE_FONT); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 198d10eb86..c34b4f9784 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -40,6 +40,7 @@ class EpubReaderActivity final : public Activity { bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit bool automaticPageTurnActive = false; uint8_t currentPageTurnOption = 0; + bool longPowerActionHandled = false; int pageLoadRetryCount = 0; bool pendingBookmarkFeedback = false; bool bookmarkFeedbackIsAdd = false; From 08efb36fa70c554f31490a474532736e3bc6bf40 Mon Sep 17 00:00:00 2001 From: Louieza23 Date: Tue, 28 Apr 2026 14:05:19 -0700 Subject: [PATCH 17/21] feat: store clippings in per-book files --- src/clippings/ClippingsManager.cpp | 34 ++++++++++++++++++++++++++---- src/clippings/ClippingsManager.h | 4 ++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/clippings/ClippingsManager.cpp b/src/clippings/ClippingsManager.cpp index b9aeb414a7..0dfc28dc9b 100644 --- a/src/clippings/ClippingsManager.cpp +++ b/src/clippings/ClippingsManager.cpp @@ -4,11 +4,37 @@ #include #include +#include "util/StringUtils.h" + +namespace { + +std::string clippingPathForBook(const std::string& bookTitle, const std::string& author) { + const std::string safeTitle = StringUtils::sanitizeFilename(bookTitle.empty() ? "book" : bookTitle, 80); + const std::string safeAuthor = StringUtils::sanitizeFilename(author, 40); + + std::string filename = safeTitle; + if (!safeAuthor.empty() && safeAuthor != "book") { + filename += " - "; + filename += safeAuthor; + } + filename += ".txt"; + + return std::string(ClippingsManager::CLIPPINGS_DIR) + "/" + filename; +} + +} + bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::string& author, const std::string& chapterTitle, int pageNumber, const std::string& selectedText) { - HalFile file = Storage.open(CLIPPINGS_PATH, O_RDWR | O_CREAT | O_AT_END); + if (!Storage.mkdir(CLIPPINGS_DIR)) { + LOG_ERR("CLIP", "Failed to create %s", CLIPPINGS_DIR); + return false; + } + + const std::string clippingPath = clippingPathForBook(bookTitle, author); + HalFile file = Storage.open(clippingPath.c_str(), O_RDWR | O_CREAT | O_AT_END); if (!file) { - LOG_ERR("CLIP", "Failed to open %s for append", CLIPPINGS_PATH); + LOG_ERR("CLIP", "Failed to open %s for append", clippingPath.c_str()); return false; } @@ -51,10 +77,10 @@ bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::str file.close(); if (!ok) { - LOG_ERR("CLIP", "Failed to write clipping to %s (SD full or removed?)", CLIPPINGS_PATH); + LOG_ERR("CLIP", "Failed to write clipping to %s (SD full or removed?)", clippingPath.c_str()); return false; } - LOG_DBG("CLIP", "Saved clipping to %s (%zu chars)", CLIPPINGS_PATH, textLen); + LOG_DBG("CLIP", "Saved clipping to %s (%zu chars)", clippingPath.c_str(), textLen); return true; } diff --git a/src/clippings/ClippingsManager.h b/src/clippings/ClippingsManager.h index 54715b1f8a..6f37e9e32e 100644 --- a/src/clippings/ClippingsManager.h +++ b/src/clippings/ClippingsManager.h @@ -4,10 +4,10 @@ class ClippingsManager { public: - // Appends a clipping entry to /My Clippings.txt on the SD card (Kindle-compatible filename). + // Appends a clipping entry to /clippings/.txt on the SD card. // Returns false if the SD write fails. static bool saveClipping(const std::string& bookTitle, const std::string& author, const std::string& chapterTitle, int pageNumber, const std::string& selectedText); - static constexpr const char* CLIPPINGS_PATH = "/My Clippings.txt"; + static constexpr const char* CLIPPINGS_DIR = "/clippings"; }; From 48cfb66cb7bd9bff65e25b6003e6331fb84cc1a9 Mon Sep 17 00:00:00 2001 From: Louieza23 Date: Sat, 2 May 2026 10:42:41 -0700 Subject: [PATCH 18/21] refine clipping UX for PR review --- lib/I18n/translations/english.yaml | 3 +- .../reader/ClipSelectionActivity.cpp | 21 ++++++++--- src/activities/reader/ClipSelectionActivity.h | 2 +- src/activities/reader/EpubReaderActivity.cpp | 37 ++++++++++++++++++- src/activities/reader/EpubReaderActivity.h | 3 ++ .../reader/EpubReaderMenuActivity.cpp | 2 +- src/clippings/ClippingsManager.cpp | 4 +- 7 files changed, 60 insertions(+), 12 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index bd7f5da4fc..af6e7d03a5 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -269,7 +269,8 @@ STR_GO_TO_PERCENT: "Go to %" STR_GO_HOME_BUTTON: "Go Home" STR_SYNC_PROGRESS: "Sync Progress" STR_DELETE_CACHE: "Delete Book Cache" -STR_SAVE_CLIPPING: "Save Clipping" +STR_SAVE_CLIPPING: "Create Clipping" +STR_CLIPPING_SAVE_FAILED: "Failed to save clipping" STR_DELETE: "Delete" STR_DISPLAY_QR: "Show page as QR" STR_CHAPTER_PREFIX: "Chapter: " diff --git a/src/activities/reader/ClipSelectionActivity.cpp b/src/activities/reader/ClipSelectionActivity.cpp index 716164fe18..721aaf927d 100644 --- a/src/activities/reader/ClipSelectionActivity.cpp +++ b/src/activities/reader/ClipSelectionActivity.cpp @@ -54,7 +54,13 @@ void ClipSelectionActivity::onEnter() { // Re-render page 0 to get a clean framebuffer — the previous activity (menu) // may still be painted on screen when onEnter() runs. - switchToPage(0); + if (!switchToPage(0)) { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + return; + } requestUpdate(); } @@ -139,7 +145,9 @@ void ClipSelectionActivity::render(RenderLock&&) { if (!savedBuffer) return; if (needsPageSwitch) { - switchToPage(words[cursorIdx].pageIdx); + if (!switchToPage(words[cursorIdx].pageIdx)) { + return; + } needsPageSwitch = false; } @@ -147,13 +155,15 @@ void ClipSelectionActivity::render(RenderLock&&) { memcpy(renderer.getFrameBuffer(), savedBuffer, savedBufferSize); drawHighlights(); - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + const auto labels = + mappedInput.mapLabels(tr(STR_BACK), startMarkIdx == -1 ? tr(STR_SELECT) : tr(STR_DONE), tr(STR_DIR_LEFT), + tr(STR_DIR_RIGHT)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } -void ClipSelectionActivity::switchToPage(int pageIdx) { +bool ClipSelectionActivity::switchToPage(int pageIdx) { const int oldPage = section.currentPage; section.currentPage = startPageInSection + pageIdx; auto page = section.loadPageFromSectionFile(); @@ -161,7 +171,7 @@ void ClipSelectionActivity::switchToPage(int pageIdx) { section.currentPage = oldPage; LOG_ERR("CLIP", "Failed to load page %d (section.currentPage=%d, currentDisplayPage=%d) — reverted", pageIdx, section.currentPage, currentDisplayPage); - return; + return false; } renderer.clearScreen(); @@ -169,6 +179,7 @@ void ClipSelectionActivity::switchToPage(int pageIdx) { // displayBuffer is intentionally omitted here — render() always controls the final display call memcpy(savedBuffer, renderer.getFrameBuffer(), savedBufferSize); currentDisplayPage = pageIdx; + return true; } void ClipSelectionActivity::drawHighlights() { diff --git a/src/activities/reader/ClipSelectionActivity.h b/src/activities/reader/ClipSelectionActivity.h index f0f1a985ab..b86c7c6954 100644 --- a/src/activities/reader/ClipSelectionActivity.h +++ b/src/activities/reader/ClipSelectionActivity.h @@ -57,7 +57,7 @@ class ClipSelectionActivity final : public Activity { ButtonNavigator buttonNavigator; Rect alignedRect(int x, int y, int w, int h) const; - void switchToPage(int pageIdx); + bool switchToPage(int pageIdx); void drawHighlights(); int lineEndForward(int idx) const; int lineEndBackward(int idx) const; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index c461981ec3..cf583c4ade 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -351,6 +351,19 @@ void EpubReaderActivity::loop() { } } + if (pendingClippingSaveFailedFeedback) { + const bool timedOut = (millis() - clippingSaveFailedFeedbackShowTime) >= 1200UL; + const bool navPressed = mappedInput.wasReleased(MappedInputManager::Button::Left) || + mappedInput.wasReleased(MappedInputManager::Button::Right) || + mappedInput.wasReleased(MappedInputManager::Button::Up) || + mappedInput.wasReleased(MappedInputManager::Button::Down); + if (timedOut || navPressed) { + pendingClippingSaveFailedFeedback = false; + requestUpdate(); + return; + } + } + if (automaticPageTurnActive) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasReleased(MappedInputManager::Button::Back)) { @@ -756,8 +769,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction if (!result.isCancelled) { const auto& clip = std::get(result.data); if (!clip.text.empty()) { - ClippingsManager::saveClipping(epub->getTitle(), epub->getAuthor(), chapterTitle, startPage + 1, - clip.text); + if (!ClippingsManager::saveClipping(epub->getTitle(), epub->getAuthor(), chapterTitle, + startPage + 1, clip.text)) { + showClippingSaveFailedFeedback(); + } } } requestUpdate(); @@ -1067,6 +1082,11 @@ void EpubReaderActivity::showCompletedFeedback(bool isCompleted) { completedFeedbackShowTime = millis(); } +void EpubReaderActivity::showClippingSaveFailedFeedback() { + pendingClippingSaveFailedFeedback = true; + clippingSaveFailedFeedbackShowTime = millis(); +} + void EpubReaderActivity::applyOrientation(const uint8_t orientation) { // No-op if the selected orientation matches current settings. if (SETTINGS.orientation == orientation) { @@ -1455,6 +1475,19 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or renderer.fillRect(toastX, toastY, toastW, toastH, true); renderer.drawText(UI_10_FONT_ID, toastX + toastPadX, toastY + toastPadY, msg, false); } + if (pendingClippingSaveFailedFeedback) { + const char* msg = tr(STR_CLIPPING_SAVE_FAILED); + constexpr int toastPadX = 20; + constexpr int toastPadY = 12; + const int msgW = renderer.getTextWidth(UI_10_FONT_ID, msg); + const int msgH = renderer.getLineHeight(UI_10_FONT_ID); + const int toastW = msgW + toastPadX * 2; + const int toastH = msgH + toastPadY * 2; + const int toastX = (renderer.getScreenWidth() - toastW) / 2; + const int toastY = (renderer.getScreenHeight() - toastH) / 2; + renderer.fillRect(toastX, toastY, toastW, toastH, true); + renderer.drawText(UI_10_FONT_ID, toastX + toastPadX, toastY + toastPadY, msg, false); + } fcm->logStats("bw_render"); const auto tBwRender = millis(); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index c34b4f9784..a6e2ff8401 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -48,6 +48,8 @@ class EpubReaderActivity final : public Activity { bool pendingCompletedFeedback = false; bool completedFeedbackIsFinished = false; unsigned long completedFeedbackShowTime = 0UL; + bool pendingClippingSaveFailedFeedback = false; + unsigned long clippingSaveFailedFeedbackShowTime = 0UL; int completionTriggerSpineIndex = -1; float completionTriggerSpineProgress = 1.0f; bool completionPromptQueued = false; @@ -96,6 +98,7 @@ class EpubReaderActivity final : public Activity { void queueCompletionPromptIfNeeded(); void setBookCompleted(bool isCompleted); void showCompletedFeedback(bool isCompleted); + void showClippingSaveFailedFeedback(); // Footnote navigation void navigateToHref(const std::string& href, bool savePosition = false); diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index c8ee4d8808..bc77b0a096 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -26,7 +26,7 @@ std::vector EpubReaderMenuActivity::buildMenuI bool isCurrentPageBookmarked, bool isBookCompleted) { std::vector items; - constexpr size_t baseItemCount = 15; + constexpr size_t baseItemCount = 14; const size_t totalItemCount = baseItemCount + (hasFootnotes ? 1u : 0u) + (hasBookmarks ? 2u : 0u); items.reserve(totalItemCount); items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); diff --git a/src/clippings/ClippingsManager.cpp b/src/clippings/ClippingsManager.cpp index 0dfc28dc9b..d4e76bc408 100644 --- a/src/clippings/ClippingsManager.cpp +++ b/src/clippings/ClippingsManager.cpp @@ -26,8 +26,8 @@ std::string clippingPathForBook(const std::string& bookTitle, const std::string& bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::string& author, const std::string& chapterTitle, int pageNumber, const std::string& selectedText) { - if (!Storage.mkdir(CLIPPINGS_DIR)) { - LOG_ERR("CLIP", "Failed to create %s", CLIPPINGS_DIR); + if (!Storage.ensureDirectoryExists(CLIPPINGS_DIR)) { + LOG_ERR("CLIP", "Failed to ensure %s exists", CLIPPINGS_DIR); return false; } From a59d708b6be0e65bb3221eb51205b4badaa732ed Mon Sep 17 00:00:00 2001 From: Louieza23 Date: Sat, 2 May 2026 11:51:28 -0700 Subject: [PATCH 19/21] refine clipping text layout --- src/clippings/ClippingsManager.cpp | 36 +++++++++++++++++++++--------- src/clippings/ClippingsManager.h | 5 +++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/clippings/ClippingsManager.cpp b/src/clippings/ClippingsManager.cpp index d4e76bc408..06041114ea 100644 --- a/src/clippings/ClippingsManager.cpp +++ b/src/clippings/ClippingsManager.cpp @@ -22,8 +22,17 @@ std::string clippingPathForBook(const std::string& bookTitle, const std::string& return std::string(ClippingsManager::CLIPPINGS_DIR) + "/" + filename; } +bool isMeaningfulChapterTitle(const std::string& chapterTitle, const std::string& bookTitle) { + if (chapterTitle.empty()) { + return false; + } + return chapterTitle != bookTitle; +} + } +bool ClippingsManager::needsFileHeader(HalFile& file) { return file.size() == 0; } + bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::string& author, const std::string& chapterTitle, int pageNumber, const std::string& selectedText) { if (!Storage.ensureDirectoryExists(CLIPPINGS_DIR)) { @@ -38,21 +47,29 @@ bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::str return false; } - // Build header and location as strings to avoid truncation of long titles/authors - const std::string header = bookTitle + " (" + author + ")\n"; + std::string fileHeader; + if (needsFileHeader(file)) { + fileHeader = bookTitle.empty() ? "book" : bookTitle; + fileHeader += "\n"; + if (!author.empty()) { + fileHeader += author; + fileHeader += "\n"; + } + fileHeader += "\n==========\n\n"; + } + std::string location = "- Your Highlight on Page " + std::to_string(pageNumber); - if (!chapterTitle.empty()) { + if (isMeaningfulChapterTitle(chapterTitle, bookTitle)) { location += " | " + chapterTitle; } - location += "\n"; + location += "\n\n"; static constexpr size_t MAX_TEXT = 2000; const size_t textLen = selectedText.size() < MAX_TEXT ? selectedText.size() : MAX_TEXT; - // Concatenate into a single buffer and perform one write to avoid partial records on SD failure - static constexpr char separator[] = "\n==========\n"; + static constexpr char separator[] = "\n\n==========\n\n"; static constexpr size_t separatorLen = sizeof(separator) - 1; - const size_t totalLen = header.size() + location.size() + 1 + textLen + separatorLen; + const size_t totalLen = fileHeader.size() + location.size() + textLen + separatorLen; auto* buf = static_cast(malloc(totalLen)); if (!buf) { @@ -62,11 +79,10 @@ bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::str } size_t pos = 0; - memcpy(buf + pos, header.c_str(), header.size()); - pos += header.size(); + memcpy(buf + pos, fileHeader.c_str(), fileHeader.size()); + pos += fileHeader.size(); memcpy(buf + pos, location.c_str(), location.size()); pos += location.size(); - buf[pos++] = '\n'; memcpy(buf + pos, selectedText.c_str(), textLen); pos += textLen; memcpy(buf + pos, separator, separatorLen); diff --git a/src/clippings/ClippingsManager.h b/src/clippings/ClippingsManager.h index 6f37e9e32e..bb2c349827 100644 --- a/src/clippings/ClippingsManager.h +++ b/src/clippings/ClippingsManager.h @@ -2,6 +2,8 @@ #include +class HalFile; + class ClippingsManager { public: // Appends a clipping entry to /clippings/.txt on the SD card. @@ -10,4 +12,7 @@ class ClippingsManager { int pageNumber, const std::string& selectedText); static constexpr const char* CLIPPINGS_DIR = "/clippings"; + + private: + static bool needsFileHeader(HalFile& file); }; From c5177779c39233376d38b3a6304abfeb1979f135 Mon Sep 17 00:00:00 2001 From: Louieza23 Date: Sat, 2 May 2026 12:02:54 -0700 Subject: [PATCH 20/21] refine clipping entry format --- src/clippings/ClippingsManager.cpp | 16 ++++++++-------- src/clippings/ClippingsManager.h | 3 --- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/clippings/ClippingsManager.cpp b/src/clippings/ClippingsManager.cpp index 06041114ea..dabb9260a4 100644 --- a/src/clippings/ClippingsManager.cpp +++ b/src/clippings/ClippingsManager.cpp @@ -30,9 +30,6 @@ bool isMeaningfulChapterTitle(const std::string& chapterTitle, const std::string } } - -bool ClippingsManager::needsFileHeader(HalFile& file) { return file.size() == 0; } - bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::string& author, const std::string& chapterTitle, int pageNumber, const std::string& selectedText) { if (!Storage.ensureDirectoryExists(CLIPPINGS_DIR)) { @@ -41,6 +38,7 @@ bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::str } const std::string clippingPath = clippingPathForBook(bookTitle, author); + const bool fileAlreadyExists = Storage.exists(clippingPath.c_str()); HalFile file = Storage.open(clippingPath.c_str(), O_RDWR | O_CREAT | O_AT_END); if (!file) { LOG_ERR("CLIP", "Failed to open %s for append", clippingPath.c_str()); @@ -48,26 +46,28 @@ bool ClippingsManager::saveClipping(const std::string& bookTitle, const std::str } std::string fileHeader; - if (needsFileHeader(file)) { + if (!fileAlreadyExists) { fileHeader = bookTitle.empty() ? "book" : bookTitle; fileHeader += "\n"; if (!author.empty()) { fileHeader += author; fileHeader += "\n"; } - fileHeader += "\n==========\n\n"; + fileHeader += "\n---\n\n"; } - std::string location = "- Your Highlight on Page " + std::to_string(pageNumber); + std::string location; if (isMeaningfulChapterTitle(chapterTitle, bookTitle)) { - location += " | " + chapterTitle; + location = chapterTitle + " - Page " + std::to_string(pageNumber); + } else { + location = "Page " + std::to_string(pageNumber); } location += "\n\n"; static constexpr size_t MAX_TEXT = 2000; const size_t textLen = selectedText.size() < MAX_TEXT ? selectedText.size() : MAX_TEXT; - static constexpr char separator[] = "\n\n==========\n\n"; + static constexpr char separator[] = "\n\n---\n\n"; static constexpr size_t separatorLen = sizeof(separator) - 1; const size_t totalLen = fileHeader.size() + location.size() + textLen + separatorLen; diff --git a/src/clippings/ClippingsManager.h b/src/clippings/ClippingsManager.h index bb2c349827..1f5db4e4b6 100644 --- a/src/clippings/ClippingsManager.h +++ b/src/clippings/ClippingsManager.h @@ -12,7 +12,4 @@ class ClippingsManager { int pageNumber, const std::string& selectedText); static constexpr const char* CLIPPINGS_DIR = "/clippings"; - - private: - static bool needsFileHeader(HalFile& file); }; From f773c2cdb3afc71c2a5defc0c2107a026f82fa8f Mon Sep 17 00:00:00 2001 From: Louieza23 Date: Sun, 3 May 2026 08:46:15 -0700 Subject: [PATCH 21/21] fix clipping page selection state --- src/activities/ActivityResult.h | 1 + .../reader/ClipSelectionActivity.cpp | 8 +- src/activities/reader/ClipSelectionActivity.h | 5 +- src/activities/reader/EpubReaderActivity.cpp | 77 ++++++++++--------- 4 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index d813f36f60..650475dade 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -53,6 +53,7 @@ struct FootnoteResult { struct ClippingResult { std::string text; + int startPageNumber = 0; }; struct BookmarkResult { diff --git a/src/activities/reader/ClipSelectionActivity.cpp b/src/activities/reader/ClipSelectionActivity.cpp index 721aaf927d..d0c0b424e1 100644 --- a/src/activities/reader/ClipSelectionActivity.cpp +++ b/src/activities/reader/ClipSelectionActivity.cpp @@ -14,14 +14,13 @@ ClipSelectionActivity::ClipSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::vector words, std::string bookTitle, std::string author, - std::string chapterTitle, int pageNumber, int fontId, Section& section, - int startPageInSection, int marginTop, int marginLeft) + std::string chapterTitle, int fontId, Section& section, int startPageInSection, + int marginTop, int marginLeft) : Activity("ClipSelection", renderer, mappedInput), words(std::move(words)), bookTitle(std::move(bookTitle)), author(std::move(author)), chapterTitle(std::move(chapterTitle)), - pageNumber(pageNumber), fontId(fontId), section(section), startPageInSection(startPageInSection), @@ -120,8 +119,9 @@ void ClipSelectionActivity::loop() { if (!text.empty()) text += ' '; text += words[i].text; } + const int startSectionPage = startPageInSection + words[from].pageIdx; ActivityResult result; - result.data = ClippingResult{std::move(text)}; + result.data = ClippingResult{std::move(text), startSectionPage + 1}; setResult(std::move(result)); finish(); } diff --git a/src/activities/reader/ClipSelectionActivity.h b/src/activities/reader/ClipSelectionActivity.h index b86c7c6954..5a9e01c828 100644 --- a/src/activities/reader/ClipSelectionActivity.h +++ b/src/activities/reader/ClipSelectionActivity.h @@ -18,8 +18,8 @@ class ClipSelectionActivity final : public Activity { }; ClipSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::vector words, - std::string bookTitle, std::string author, std::string chapterTitle, int pageNumber, int fontId, - Section& section, int startPageInSection, int marginTop, int marginLeft); + std::string bookTitle, std::string author, std::string chapterTitle, int fontId, Section& section, + int startPageInSection, int marginTop, int marginLeft); void onEnter() override; void onExit() override; @@ -36,7 +36,6 @@ class ClipSelectionActivity final : public Activity { std::string bookTitle; std::string author; std::string chapterTitle; - int pageNumber; int fontId; Section& section; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index cf583c4ade..af2f652537 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -717,60 +717,63 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction const int readerFontId = SETTINGS.getReaderFontId(); const int lineH = renderer.getLineHeight(readerFontId); - const int startPage = section->currentPage; - const int pagesToLoad = std::min(3, section->pageCount - startPage); + int startPage = 0; + std::string chapterTitle; std::vector words; - words.reserve(pagesToLoad * 60); - - for (int pi = 0; pi < pagesToLoad; ++pi) { - section->currentPage = startPage + pi; - auto page = section->loadPageFromSectionFile(); - if (!page) { - break; - } - - for (const auto& el : page->elements) { - if (el->getTag() != TAG_PageLine) { - continue; + { + RenderLock lock(*this); + startPage = section->currentPage; + const int pagesToLoad = std::min(3, section->pageCount - startPage); + words.reserve(pagesToLoad * 60); + + for (int pi = 0; pi < pagesToLoad; ++pi) { + section->currentPage = startPage + pi; + auto page = section->loadPageFromSectionFile(); + if (!page) { + break; } - const auto& line = static_cast(*el); - if (!line.getBlock()) { - continue; - } - const auto& block = *line.getBlock(); - const auto& xpos = block.getWordXpos(); - const auto& wlist = block.getWords(); - - for (int i = 0; i < static_cast(wlist.size()); ++i) { - const int wx = mLeft + line.xPos + xpos[i]; - const int wy = mTop + line.yPos; - const int ww = renderer.getTextWidth(readerFontId, wlist[i].c_str()); - if (ww > 0) { - words.push_back({wx, wy, ww, lineH, pi, wlist[i]}); + + for (const auto& el : page->elements) { + if (el->getTag() != TAG_PageLine) { + continue; + } + const auto& line = static_cast(*el); + if (!line.getBlock()) { + continue; + } + const auto& block = *line.getBlock(); + const auto& xpos = block.getWordXpos(); + const auto& wlist = block.getWords(); + + for (int i = 0; i < static_cast(wlist.size()); ++i) { + const int wx = mLeft + line.xPos + xpos[i]; + const int wy = mTop + line.yPos; + const int ww = renderer.getTextWidth(readerFontId, wlist[i].c_str()); + if (ww > 0) { + words.push_back({wx, wy, ww, lineH, pi, wlist[i]}); + } } } } - } - section->currentPage = startPage; - - if (!words.empty()) { - std::string chapterTitle; + section->currentPage = startPage; const int tocIdx = epub->getTocIndexForSpineIndex(currentSpineIndex); if (tocIdx >= 0) { chapterTitle = epub->getTocItem(tocIdx).title; } + } + if (!words.empty()) { startActivityForResult( std::make_unique(renderer, mappedInput, std::move(words), epub->getTitle(), - epub->getAuthor(), chapterTitle, startPage + 1, readerFontId, - *section, startPage, mTop, mLeft), - [this, chapterTitle, startPage](const ActivityResult& result) { + epub->getAuthor(), chapterTitle, readerFontId, *section, startPage, + mTop, mLeft), + [this, chapterTitle](const ActivityResult& result) { if (!result.isCancelled) { const auto& clip = std::get(result.data); if (!clip.text.empty()) { if (!ClippingsManager::saveClipping(epub->getTitle(), epub->getAuthor(), chapterTitle, - startPage + 1, clip.text)) { + clip.startPageNumber, clip.text)) { showClippingSaveFailedFeedback(); } }