diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index 03231eb231..a70811244d 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -42,6 +42,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 a4fb5a3b45..e36e2b990a 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -504,6 +504,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; @@ -908,6 +943,41 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const display.displayBuffer(refreshMode, fadingFix); } +void GfxRenderer::displayWindow(int x, int y, int width, int height) const { + 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, const EpdFontFamily::Style style) const { if (!text || maxWidth <= 0) return ""; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index a9b3986036..959ea5552d 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -89,8 +89,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; @@ -105,6 +105,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/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index 69c741a728..a3abdd23be 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -231,6 +231,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 01632093e1..733aff2dc2 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -254,6 +254,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 85361d638a..7a57acb160 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" @@ -231,6 +232,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 ce98247ba7..afd98d3165 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -254,6 +254,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 e949e375fe..8f9aec15cb 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -254,6 +254,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 8a3b7acd1a..af6e7d03a5 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -236,6 +236,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" @@ -268,6 +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: "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/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 1ffba1cfc7..e81cde93b9 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -231,6 +231,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 20d11e4b78..3d15441073 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" @@ -254,6 +255,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 50908cc795..9656c94e48 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -226,6 +226,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" @@ -255,6 +256,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 5efd987c76..b30d35784a 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -254,6 +254,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 661d9e3f98..31596b7458 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -256,6 +256,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 0da3c7bb1a..7c11c84653 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.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/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 2f3b1b9c6c..cb52ca0391 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -254,6 +254,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 4e57d0c345..2eef161105 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -254,6 +254,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 ab5913bb93..a162a48ae9 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" @@ -231,6 +232,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 d491190f26..ec76a82912 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -254,6 +254,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 a2cf039435..27985db284 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: "Переназначить передние кнопки" @@ -257,6 +258,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 447f7f95b0..a3f3c15645 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -254,6 +254,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 d330c028bd..9aad2c91cb 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" @@ -254,6 +255,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 cf8decec8e..05418009e2 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" @@ -258,6 +259,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:" @@ -294,6 +296,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" @@ -318,6 +326,5 @@ STR_KB_HINT_URL_SNIPPETS: "Tryck på URL för URL-fragment" STR_REMAP_FRONT_BUTTONS_READER: "Ändra frontknappar" STR_READING_STATS: "Lässtatistik" STR_BOOKMARKS: "Bokmärken" -STR_OPDS_SERVERS: "OPDS-servrar" STR_LONG_PRESS_MENU_ACTION: "Menyåtgärd vid långtryck" STR_CYCLE_PAGE_TURN: "Automatisk vändning" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 5ef8b29dad..6beb3863fd 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -231,6 +231,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: "Seçimi 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 ce66065f96..19df330c10 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -255,6 +255,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..17635b4943 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -58,6 +58,16 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) einkDisplay.displayBuffer(convertRefreshMode(mode), 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)); +} + 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/lib/hal/HalStorage.cpp b/lib/hal/HalStorage.cpp index 4573f1e71e..97890fa668 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/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/ActivityManager.cpp b/src/activities/ActivityManager.cpp index ca202d82e0..006c805425 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -286,17 +286,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; } @@ -304,6 +313,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/ActivityResult.h b/src/activities/ActivityResult.h index 4b1f326838..650475dade 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -51,13 +51,19 @@ struct FootnoteResult { std::string href; }; +struct ClippingResult { + std::string text; + int startPageNumber = 0; +}; + struct BookmarkResult { uint16_t spineIndex = 0; float progress = 0.0f; }; using ResultVariant = std::variant; + PageResult, SyncResult, NetworkModeResult, FootnoteResult, ClippingResult, + BookmarkResult>; 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..d0c0b424e1 --- /dev/null +++ b/src/activities/reader/ClipSelectionActivity.cpp @@ -0,0 +1,334 @@ +#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 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)), + 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; + } + + savedSectionPage = section.currentPage; + 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. + if (!switchToPage(0)) { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + return; + } + requestUpdate(); +} + +void ClipSelectionActivity::onExit() { + section.currentPage = savedSectionPage; + free(savedBuffer); + savedBuffer = nullptr; + Activity::onExit(); +} + +void ClipSelectionActivity::loop() { + const int total = static_cast(words.size()); + + buttonNavigator.onRelease({MappedInputManager::Button::Right}, [this, total] { + if (cursorIdx + 1 >= total) return; + moveCursorToIndex(cursorIdx + 1); + }); + + buttonNavigator.onContinuous({MappedInputManager::Button::Right}, [this] { + const int next = lineEndForward(cursorIdx); + if (next == cursorIdx) return; + moveCursorToIndex(next); + }); + + buttonNavigator.onRelease({MappedInputManager::Button::Left}, [this] { + if (cursorIdx == 0) return; + moveCursorToIndex(cursorIdx - 1); + }); + + buttonNavigator.onContinuous({MappedInputManager::Button::Left}, [this] { + const int prev = lineEndBackward(cursorIdx); + if (prev == cursorIdx) return; + 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)) { + 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; + } + const int startSectionPage = startPageInSection + words[from].pageIdx; + ActivityResult result; + result.data = ClippingResult{std::move(text), startSectionPage + 1}; + 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) { + if (!switchToPage(words[cursorIdx].pageIdx)) { + return; + } + 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), 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); +} + +bool ClipSelectionActivity::switchToPage(int pageIdx) { + const int oldPage = section.currentPage; + section.currentPage = startPageInSection + 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); + return false; + } + + 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; + return true; +} + +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; + 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); + } + } + + // Draw cursor highlight (always on top) + const auto& cw = words[cursorIdx]; + if (cw.pageIdx == currentDisplayPage) { + 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); + } +} + +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 first word of next line + if (last == idx && idx + 1 < total) { + return idx + 1; + } + + 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 last word of previous line + if (first == idx && idx - 1 >= 0) { + return idx - 1; + } + + 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 new file mode 100644 index 0000000000..5a9e01c828 --- /dev/null +++ b/src/activities/reader/ClipSelectionActivity.h @@ -0,0 +1,65 @@ +#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 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 fontId; + + Section& section; + int startPageInSection; + int marginTop; + int marginLeft; + + uint8_t* savedBuffer = nullptr; + size_t savedBufferSize = 0; + int currentDisplayPage = 0; + int savedSectionPage = 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; + bool switchToPage(int pageIdx); + 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 fe17e6ef35..c57263ebb4 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -17,6 +17,7 @@ #include "../settings/KOReaderSettingsActivity.h" #include "BookStatsActivity.h" +#include "ClipSelectionActivity.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "EpubReaderBookmarkListActivity.h" @@ -31,6 +32,7 @@ #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "activities/util/ConfirmationActivity.h" +#include "clippings/ClippingsManager.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/ScreenshotUtil.h" @@ -345,6 +347,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)) { @@ -708,6 +723,84 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); break; } + case EpubReaderMenuActivity::MenuAction::SAVE_CLIPPING: { + if (section && epub) { + 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); + int startPage = 0; + std::string chapterTitle; + + std::vector words; + { + 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; + } + + 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; + 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, 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, + clip.startPageNumber, clip.text)) { + showClippingSaveFailedFeedback(); + } + } + } + requestUpdate(); + }); + } else { + requestUpdate(); + } + } + break; + } case EpubReaderMenuActivity::MenuAction::READING_STATS: { // Include elapsed time from the current session in the display stats. BookReadingStats displayStats = stats; @@ -868,6 +961,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) { @@ -918,6 +1014,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; } @@ -975,6 +1074,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; } @@ -1014,6 +1116,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) { @@ -1402,6 +1509,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 27b21834f2..8f59dac87e 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -49,6 +49,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; @@ -99,6 +101,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 947ed47625..bc77b0a096 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -48,6 +48,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}); items.push_back( {MenuAction::TOGGLE_COMPLETED, isBookCompleted ? StrId::STR_MARK_UNFINISHED : StrId::STR_MARK_FINISHED}); items.push_back({MenuAction::READING_STATS, StrId::STR_READING_STATS}); diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index a4a33efe47..79bbb5d4c6 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -23,6 +23,7 @@ class EpubReaderMenuActivity final : public Activity { GO_HOME, SYNC, DELETE_CACHE, + SAVE_CLIPPING, READING_STATS, TOGGLE_COMPLETED, READER_OPTIONS, diff --git a/src/clippings/ClippingsManager.cpp b/src/clippings/ClippingsManager.cpp new file mode 100644 index 0000000000..dabb9260a4 --- /dev/null +++ b/src/clippings/ClippingsManager.cpp @@ -0,0 +1,102 @@ +#include "ClippingsManager.h" + +#include +#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 isMeaningfulChapterTitle(const std::string& chapterTitle, const std::string& bookTitle) { + if (chapterTitle.empty()) { + return false; + } + return chapterTitle != bookTitle; +} + +} +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)) { + LOG_ERR("CLIP", "Failed to ensure %s exists", CLIPPINGS_DIR); + return false; + } + + 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()); + return false; + } + + std::string fileHeader; + if (!fileAlreadyExists) { + fileHeader = bookTitle.empty() ? "book" : bookTitle; + fileHeader += "\n"; + if (!author.empty()) { + fileHeader += author; + fileHeader += "\n"; + } + fileHeader += "\n---\n\n"; + } + + std::string location; + if (isMeaningfulChapterTitle(chapterTitle, bookTitle)) { + 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 size_t separatorLen = sizeof(separator) - 1; + const size_t totalLen = fileHeader.size() + location.size() + textLen + separatorLen; + + auto* buf = static_cast(malloc(totalLen)); + if (!buf) { + LOG_ERR("CLIP", "malloc failed: %zu bytes", totalLen); + file.close(); + return false; + } + + size_t pos = 0; + memcpy(buf + pos, fileHeader.c_str(), fileHeader.size()); + pos += fileHeader.size(); + memcpy(buf + pos, location.c_str(), location.size()); + pos += location.size(); + 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(); + + if (!ok) { + 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)", clippingPath.c_str(), textLen); + return true; +} diff --git a/src/clippings/ClippingsManager.h b/src/clippings/ClippingsManager.h new file mode 100644 index 0000000000..1f5db4e4b6 --- /dev/null +++ b/src/clippings/ClippingsManager.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +class HalFile; + +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_DIR = "/clippings"; +};