From 9d19354e6f9c07965389ab9f3ed3f7bbfe6dcccc Mon Sep 17 00:00:00 2001 From: Eric Morrison Date: Tue, 24 Mar 2026 18:41:01 -0700 Subject: [PATCH 1/2] Add ebook cover preview notes --- NOTES.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 NOTES.md diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..2f1ce91 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,36 @@ +# Pixel Reader Notes + +## Project + +Add lightweight ebook cover support to Pixel Reader for Onion on the Miyoo Mini+. + +## Goal + +Improve browsing books without turning the app into a heavy bookshelf UI. + +## Current Understanding + +- The file browser is currently a text-only list of filenames. +- EPUB metadata parsing currently handles manifest, spine, and TOC, but not title/author/cover metadata. +- The reader already supports rendering images from inside EPUB content. +- A lightweight cover preview is more realistic than a full gallery view. + +## Likely MVP + +- Extract cover metadata from EPUB files. +- Load the selected book's cover when a file is focused. +- Show a small preview pane or modal for the highlighted book. +- Cache decoded cover images so browsing stays responsive. + +## Constraints + +- Keep the UI simple and readable on a small screen. +- Avoid heavy parsing on every scroll event. +- Keep the change realistic for upstream contribution. + +## Next Steps + +1. Inspect EPUB metadata flow and identify where cover references should be parsed. +2. Inspect file browser rendering path and choose the smallest UI change that can display a cover. +3. Decide whether to parse embedded covers directly or support sidecar/cached cover images first. +4. Build a minimal prototype. From 30cdfcec53d0fc74ff725e1419d75cff5d982914 Mon Sep 17 00:00:00 2001 From: Eric Morrison Date: Tue, 24 Mar 2026 22:13:30 -0700 Subject: [PATCH 2/2] Add ebook browser cover previews and metadata loading --- Makefile | 24 +- cross-compile/miyoo-mini/create_packages.sh | 4 +- src/filetypes/epub/epub_cover.cpp | 106 ++ src/filetypes/epub/epub_cover.h | 21 + src/filetypes/epub/epub_metadata.cpp | 147 ++ src/filetypes/epub/epub_metadata.h | 4 + src/filetypes/epub/epub_reader.cpp | 2 +- .../epub/tests/epub_metadata_test.cpp | 92 + src/reader/main.cpp | 4 +- src/reader/state_store.cpp | 7 + src/reader/state_store.h | 1 + src/reader/views/file_selector.cpp | 1514 ++++++++++++++++- src/reader/views/file_selector.h | 11 +- src/util/sdl_image_cache.cpp | 4 +- src/util/sdl_image_cache.h | 2 +- src/util/task_queue.cpp | 20 +- src/util/task_queue.h | 2 + 17 files changed, 1891 insertions(+), 74 deletions(-) create mode 100644 src/filetypes/epub/epub_cover.cpp create mode 100644 src/filetypes/epub/epub_cover.h diff --git a/Makefile b/Makefile index 68ca930..4b09222 100644 --- a/Makefile +++ b/Makefile @@ -4,10 +4,12 @@ ifeq (,$(PLATFORM)) endif PREFIX ?= /usr +UNAME_S := $(shell uname -s) +HOMEBREW_PREFIX ?= /opt/homebrew WARNFLAGS := -pedantic-errors -Wall -Wextra -CXXFLAGS := -std=c++17 -O2 -LDFLAGS := -lstdc++ -lSDL -lSDL_ttf -lSDL_image -lzip -lxml2 -lstdc++fs +CXXFLAGS := -std=c++17 -O2 -pthread +LDFLAGS := -pthread -lstdc++ -lSDL -lSDL_ttf -lSDL_image -lzip -lxml2 -lstdc++fs ifeq ($(PLATFORM),miyoomini) CXXFLAGS := $(CXXFLAGS) \ @@ -28,6 +30,24 @@ OBJ_DIR := $(BUILD)/objects APP_DIR := $(BUILD) INCLUDE := -Isrc -I${SYSROOT}${PREFIX}/include/libxml2 +ifeq ($(UNAME_S),Darwin) +INCLUDE := -Icompat_include \ + -I$(HOMEBREW_PREFIX)/opt/sdl12-compat/include \ + -I$(HOMEBREW_PREFIX)/opt/sdl2/include \ + -I$(HOMEBREW_PREFIX)/opt/sdl2/include/SDL2 \ + -I$(HOMEBREW_PREFIX)/opt/sdl2_ttf/include \ + -I$(HOMEBREW_PREFIX)/opt/sdl2_image/include \ + -I$(HOMEBREW_PREFIX)/opt/libxml2/include/libxml2 \ + -I$(HOMEBREW_PREFIX)/opt/libzip/include \ + -Isrc +LDFLAGS := -L$(HOMEBREW_PREFIX)/opt/sdl12-compat/lib \ + -L$(HOMEBREW_PREFIX)/opt/sdl2_ttf/lib \ + -L$(HOMEBREW_PREFIX)/opt/sdl2_image/lib \ + -L$(HOMEBREW_PREFIX)/opt/libxml2/lib \ + -L$(HOMEBREW_PREFIX)/opt/libzip/lib \ + -lstdc++ -lSDL -lSDL2_ttf -lSDL2_image -lzip -lxml2 -lstdc++fs +endif + ROTOZOOM_SRC := src/extern/rotozoom/SDL_rotozoom.c COMMON_SRC := $(filter-out src/reader/main.cpp, $(wildcard src/filetypes/*.cpp src/filetypes/txt/*.cpp src/filetypes/epub/*.cpp src/reader/*.cpp src/reader/views/*.cpp src/reader/views/token_view/*.cpp src/sys/*.cpp src/util/*.cpp src/doc_api/*.cpp src/extern/hash-library/*.cpp)) READER_SRC := $(COMMON_SRC) src/reader/main.cpp diff --git a/cross-compile/miyoo-mini/create_packages.sh b/cross-compile/miyoo-mini/create_packages.sh index aaf9ef6..a4ef1a0 100755 --- a/cross-compile/miyoo-mini/create_packages.sh +++ b/cross-compile/miyoo-mini/create_packages.sh @@ -14,8 +14,10 @@ if [ -z "$VERSION" ]; then fi echo "Version v${VERSION}" +MAKE_JOBS=${MAKE_JOBS:-1} + make clean -make -j +make -j"${MAKE_JOBS}" stage_common() { local STAGE_ROOT=$1 diff --git a/src/filetypes/epub/epub_cover.cpp b/src/filetypes/epub/epub_cover.cpp new file mode 100644 index 0000000..7a5a56d --- /dev/null +++ b/src/filetypes/epub/epub_cover.cpp @@ -0,0 +1,106 @@ +#include "./epub_cover.h" + +#include "./epub_metadata.h" +#include "util/zip_utils.h" + +#include +#include + +namespace +{ + +bool media_type_is_image(const std::string &media_type) +{ + return media_type.rfind("image/", 0) == 0; +} + +bool load_epub_info( + const std::filesystem::path &book_path, + EpubCover &out_cover, + bool include_cover_data +) +{ + int err = 0; + zip_t *zip = zip_open(book_path.c_str(), ZIP_RDONLY, &err); + if (zip == nullptr) + { + std::cerr << "Failed to open epub " << book_path + << " code: " << err + << std::endl; + return false; + } + + auto close_zip = [&zip]() { + if (zip) + { + zip_close(zip); + zip = nullptr; + } + }; + + auto container_xml = read_zip_file_str(zip, EPUB_CONTAINER_PATH); + if (container_xml.empty()) + { + close_zip(); + return false; + } + + auto rootfile_path = epub_parse_rootfile_path(container_xml.data()); + if (rootfile_path.empty()) + { + close_zip(); + return false; + } + + auto package_xml = read_zip_file_str(zip, rootfile_path); + if (package_xml.empty()) + { + close_zip(); + return false; + } + + PackageContents package; + if (!epub_parse_package_contents(rootfile_path, package_xml.data(), package)) + { + close_zip(); + return false; + } + + out_cover.title = package.title; + out_cover.author = package.author; + out_cover.href_absolute = package.cover_href_absolute; + out_cover.media_type = package.cover_media_type; + out_cover.data.clear(); + if (include_cover_data && + !package.cover_href_absolute.empty() && + media_type_is_image(package.cover_media_type)) + { + out_cover.data = read_zip_file_str(zip, package.cover_href_absolute); + } + + close_zip(); + + return true; +} + +} // namespace + +bool epub_load_preview_info(const std::filesystem::path &book_path, EpubCover &out_cover) +{ + return load_epub_info(book_path, out_cover, true); +} + +bool epub_load_book_metadata(const std::filesystem::path &book_path, EpubCover &out_cover) +{ + return load_epub_info(book_path, out_cover, false); +} + +bool epub_load_cover(const std::filesystem::path &book_path, EpubCover &out_cover) +{ + if (!epub_load_preview_info(book_path, out_cover)) + { + return false; + } + + return !out_cover.data.empty(); +} diff --git a/src/filetypes/epub/epub_cover.h b/src/filetypes/epub/epub_cover.h new file mode 100644 index 0000000..fdfca16 --- /dev/null +++ b/src/filetypes/epub/epub_cover.h @@ -0,0 +1,21 @@ +#ifndef EPUB_COVER_H_ +#define EPUB_COVER_H_ + +#include +#include +#include + +struct EpubCover +{ + std::string title; + std::string author; + std::string href_absolute; + std::string media_type; + std::vector data; +}; + +bool epub_load_preview_info(const std::filesystem::path &book_path, EpubCover &out_cover); +bool epub_load_book_metadata(const std::filesystem::path &book_path, EpubCover &out_cover); +bool epub_load_cover(const std::filesystem::path &book_path, EpubCover &out_cover); + +#endif diff --git a/src/filetypes/epub/epub_metadata.cpp b/src/filetypes/epub/epub_metadata.cpp index 9a63e71..6150611 100644 --- a/src/filetypes/epub/epub_metadata.cpp +++ b/src/filetypes/epub/epub_metadata.cpp @@ -5,6 +5,7 @@ #include +#include #include #include #include @@ -75,6 +76,98 @@ std::string epub_parse_rootfile_path(const char *container_xml) namespace parse_package { +xmlNodePtr metadata_first_child(xmlNodePtr node) +{ + node = elem_first_child(elem_first_by_name(node, BAD_CAST "package")); + return elem_first_child(elem_first_by_name(node, BAD_CAST "metadata")); +} + +std::string parse_metadata_text(xmlNodePtr node, const xmlChar *name) +{ + node = metadata_first_child(node); + node = elem_first_by_name(node, name); + while (node) + { + xmlChar *content = xmlNodeGetContent(node); + if (content) + { + auto text = strip_whitespace((const char *)content); + xmlFree(content); + if (!text.empty()) + { + return text; + } + } + + node = elem_next_by_name(node, name); + } + + return {}; +} + +bool manifest_item_has_property(const ManifestItem &item, const std::string &property) +{ + size_t start_pos = 0; + while (start_pos < item.properties.size()) + { + size_t end_pos = item.properties.find(' ', start_pos); + if (end_pos == std::string::npos) + { + end_pos = item.properties.size(); + } + + if (item.properties.substr(start_pos, end_pos - start_pos) == property) + { + return true; + } + + start_pos = end_pos + 1; + } + + return false; +} + +std::string parse_metadata_cover_id(xmlNodePtr node) +{ + node = metadata_first_child(node); + node = elem_first_by_name(node, BAD_CAST "meta"); + + while (node) + { + const char *name = (const char *)xmlGetProp(node, BAD_CAST "name"); + const char *content = (const char *)xmlGetProp(node, BAD_CAST "content"); + if (name && content && strcmp(name, "cover") == 0 && content[0] != 0) + { + return content; + } + + node = elem_next_by_name(node, BAD_CAST "meta"); + } + + return {}; +} + +std::string parse_guide_cover_href(const std::filesystem::path &base_path, xmlNodePtr node) +{ + node = elem_first_child(elem_first_by_name(node, BAD_CAST "package")); + node = elem_first_child(elem_first_by_name(node, BAD_CAST "guide")); + node = elem_first_by_name(node, BAD_CAST "reference"); + + while (node) + { + const char *type = (const char *)xmlGetProp(node, BAD_CAST "type"); + const char *href = (const char *)xmlGetProp(node, BAD_CAST "href"); + if (type && href && strcmp(type, "cover") == 0 && href[0] != 0) + { + return (base_path / href).lexically_normal(); + } + + node = elem_next_by_name(node, BAD_CAST "reference"); + } + + return {}; +} + std::unordered_map parse_package_manifest(const std::filesystem::path &base_path, xmlNodePtr node) { std::unordered_map manifest; @@ -145,6 +238,10 @@ bool epub_parse_package_contents(const std::string &rootfile_path, const char *p out_package.id_to_manifest_item = parse_package::parse_package_manifest(base_path, node); out_package.spine_ids = parse_package::parse_package_spine(node); + out_package.title = parse_package::parse_metadata_text(node, BAD_CAST "title"); + out_package.author = parse_package::parse_metadata_text(node, BAD_CAST "creator"); + out_package.cover_href_absolute.clear(); + out_package.cover_media_type.clear(); // get toc id from spine { @@ -162,6 +259,56 @@ bool epub_parse_package_contents(const std::string &rootfile_path, const char *p } } + // Look for a cover image using common EPUB 2/3 patterns. + { + auto cover_item = std::find_if( + out_package.id_to_manifest_item.begin(), + out_package.id_to_manifest_item.end(), + [](const auto &item) { + return parse_package::manifest_item_has_property(item.second, "cover-image"); + } + ); + if (cover_item != out_package.id_to_manifest_item.end()) + { + out_package.cover_href_absolute = cover_item->second.href_absolute; + out_package.cover_media_type = cover_item->second.media_type; + } + } + + if (out_package.cover_href_absolute.empty()) + { + auto cover_id = parse_package::parse_metadata_cover_id(node); + if (!cover_id.empty()) + { + auto item_it = out_package.id_to_manifest_item.find(cover_id); + if (item_it != out_package.id_to_manifest_item.end()) + { + out_package.cover_href_absolute = item_it->second.href_absolute; + out_package.cover_media_type = item_it->second.media_type; + } + } + } + + if (out_package.cover_href_absolute.empty()) + { + auto guide_href = parse_package::parse_guide_cover_href(base_path, node); + if (!guide_href.empty()) + { + auto item_it = std::find_if( + out_package.id_to_manifest_item.begin(), + out_package.id_to_manifest_item.end(), + [&guide_href](const auto &item) { + return item.second.href_absolute == guide_href; + } + ); + if (item_it != out_package.id_to_manifest_item.end()) + { + out_package.cover_href_absolute = item_it->second.href_absolute; + out_package.cover_media_type = item_it->second.media_type; + } + } + } + xmlFreeDoc(package_doc); return true; diff --git a/src/filetypes/epub/epub_metadata.h b/src/filetypes/epub/epub_metadata.h index 8eb47a8..ca5665b 100644 --- a/src/filetypes/epub/epub_metadata.h +++ b/src/filetypes/epub/epub_metadata.h @@ -38,6 +38,10 @@ struct PackageContents std::unordered_map id_to_manifest_item; std::vector spine_ids; std::string toc_id; + std::string title; + std::string author; + std::string cover_href_absolute; + std::string cover_media_type; }; std::string epub_parse_rootfile_path(const char *container_xml); diff --git a/src/filetypes/epub/epub_reader.cpp b/src/filetypes/epub/epub_reader.cpp index da99e01..fe858e5 100644 --- a/src/filetypes/epub/epub_reader.cpp +++ b/src/filetypes/epub/epub_reader.cpp @@ -62,7 +62,7 @@ EPubReader::EPubReader(std::filesystem::path path) EPubReader::~EPubReader() { - if (!state->zip) + if (state->zip) { zip_close(state->zip); } diff --git a/src/filetypes/epub/tests/epub_metadata_test.cpp b/src/filetypes/epub/tests/epub_metadata_test.cpp index bf295e1..202a278 100644 --- a/src/filetypes/epub/tests/epub_metadata_test.cpp +++ b/src/filetypes/epub/tests/epub_metadata_test.cpp @@ -9,6 +9,98 @@ TEST(EPUB_METADATA, epub_parse_ncx__invalid_xml) ASSERT_TRUE(navmap.empty()); } +TEST(EPUB_METADATA, epub_parse_package_contents__finds_epub3_cover_image) +{ + const char *xml = ( + "" + "" + " " + " " + " " + " " + " " + " " + " " + " " + "" + ); + + PackageContents package; + ASSERT_TRUE(epub_parse_package_contents("OPS/package.opf", xml, package)); + ASSERT_EQ(package.cover_href_absolute, "OPS/images/cover.jpg"); + ASSERT_EQ(package.cover_media_type, "image/jpeg"); +} + +TEST(EPUB_METADATA, epub_parse_package_contents__parses_title_and_author) +{ + const char *xml = ( + "" + "" + " " + " The Left Hand of Darkness " + " Ursula K. Le Guin " + " " + " " + " " + " " + " " + " " + " " + "" + ); + + PackageContents package; + ASSERT_TRUE(epub_parse_package_contents("OPS/package.opf", xml, package)); + ASSERT_EQ(package.title, "The Left Hand of Darkness"); + ASSERT_EQ(package.author, "Ursula K. Le Guin"); +} + +TEST(EPUB_METADATA, epub_parse_package_contents__finds_epub2_cover_meta) +{ + const char *xml = ( + "" + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + "" + ); + + PackageContents package; + ASSERT_TRUE(epub_parse_package_contents("OPS/package.opf", xml, package)); + ASSERT_EQ(package.cover_href_absolute, "OPS/cover.png"); + ASSERT_EQ(package.cover_media_type, "image/png"); +} + +TEST(EPUB_METADATA, epub_parse_package_contents__falls_back_to_guide_cover_reference) +{ + const char *xml = ( + "" + "" + " " + " " + " " + " " + " " + " " + " " + " " + "" + ); + + PackageContents package; + ASSERT_TRUE(epub_parse_package_contents("OPS/package.opf", xml, package)); + ASSERT_EQ(package.cover_href_absolute, "OPS/images/cover.png"); + ASSERT_EQ(package.cover_media_type, "image/png"); +} + TEST(EPUB_METADATA, epub_parse_ncx__navmap_not_found) { const char *xml = ( diff --git a/src/reader/main.cpp b/src/reader/main.cpp index fe7ff34..fa7d0ea 100644 --- a/src/reader/main.cpp +++ b/src/reader/main.cpp @@ -72,7 +72,9 @@ void initialize_views( auto browse_path = state_store.get_current_browse_path().value_or(DEFAULT_BROWSE_PATH); std::shared_ptr fs = std::make_shared( browse_path, - sys_styling + state_store, + sys_styling, + [&task_queue](task_func task) { task_queue.submit(std::move(task)); } ); fs->set_on_file_selected(load_book); diff --git a/src/reader/state_store.cpp b/src/reader/state_store.cpp index 9a16b19..2cf01c8 100644 --- a/src/reader/state_store.cpp +++ b/src/reader/state_store.cpp @@ -175,6 +175,13 @@ std::optional StateStore::get_book_address(const std::string &book_id) return cache; } +std::optional StateStore::read_book_address_from_disk(const std::string &book_id) const +{ + return load_book_address( + address_store_path_for_book(book_data_root_path, book_id) + ); +} + void StateStore::set_book_address(const std::string &book_id, DocAddr address) { auto it = book_addresses.find(book_id); diff --git a/src/reader/state_store.h b/src/reader/state_store.h index a04ae4a..eb871c5 100644 --- a/src/reader/state_store.h +++ b/src/reader/state_store.h @@ -46,6 +46,7 @@ class StateStore { // book addresses std::optional get_book_address(const std::string &book_id) const; + std::optional read_book_address_from_disk(const std::string &book_id) const; void set_book_address(const std::string &book_id, DocAddr address); // reader cache diff --git a/src/reader/views/file_selector.cpp b/src/reader/views/file_selector.cpp index 45befa2..7e9e502 100644 --- a/src/reader/views/file_selector.cpp +++ b/src/reader/views/file_selector.cpp @@ -1,96 +1,1021 @@ #include "./file_selector.h" -#include "./selection_menu.h" +#include "extern/rotozoom/SDL_rotozoom.h" +#include "filetypes/epub/epub_cover.h" #include "filetypes/open_doc.h" +#include "reader/shoulder_keymap.h" +#include "reader/state_store.h" #include "reader/system_styling.h" #include "sys/filesystem.h" +#include "sys/keymap.h" +#include "sys/screen.h" +#include "util/sdl_font_cache.h" +#include "util/sdl_image_cache.h" +#include "util/sdl_utils.h" +#include "util/str_utils.h" +#include "util/throttled.h" +#include "util/utf8.h" +#include + +#include +#include #include #include +#include +#include +#include +#include +#include +#include #include -struct FSState +namespace +{ + +const int LINE_PADDING = 4; +const int ENTRY_GAP = 5; +const int PREVIEW_PANEL_PADDING = 10; +const int PREVIEW_PANEL_GAP = 8; +const int PREVIEW_BORDER_WIDTH = 2; + +struct PreviewRequest +{ + uint64_t request_id; + std::filesystem::path book_path; +}; + +struct IndexRequest +{ + uint64_t request_id; + std::filesystem::path directory_path; + std::vector path_entries; +}; + +struct PreviewLoadResult +{ + uint64_t request_id = 0; + std::filesystem::path book_path; + std::string title; + std::string author; + std::string progress_label; + std::filesystem::path cover_path; + std::string media_type; + std::vector data; +}; + +struct IndexProgress +{ + uint64_t request_id = 0; + std::filesystem::path book_path; + std::string title; + std::string author; + std::string progress_label; + uint32_t indexed_books = 0; + uint32_t total_books = 0; + bool finished = false; +}; + +struct PreviewTextInfo +{ + std::string title; + std::string author; +}; + +enum class SortMode +{ + TITLE, + AUTHOR, +}; + +bool path_is_epub(const std::filesystem::path &path) +{ + return to_lower(path.extension()) == ".epub"; +} + +std::string image_format_for_preview(const PreviewLoadResult &result) +{ + if (result.cover_path.has_extension()) + { + auto ext = result.cover_path.extension().string(); + if (ext.size() > 1) + { + return ext.substr(1); + } + } + + if (result.media_type == "image/jpeg") + { + return "jpg"; + } + if (result.media_type == "image/png") + { + return "png"; + } + if (result.media_type == "image/gif") + { + return "gif"; + } + if (result.media_type == "image/bmp") + { + return "bmp"; + } + + return {}; +} + +bool text_fits(TTF_Font *font, const std::string &text, int max_width) +{ + if (max_width <= 0) + { + return false; + } + + int text_width = 0; + int text_height = 0; + return TTF_SizeUTF8(font, text.c_str(), &text_width, &text_height) == 0 && text_width <= max_width; +} + +std::string truncate_text_to_width(TTF_Font *font, const std::string &text, int max_width) +{ + if (text.empty() || max_width <= 0) + { + return {}; + } + + if (text_fits(font, text, max_width)) + { + return text; + } + + const std::string suffix = "..."; + if (!text_fits(font, suffix, max_width)) + { + return {}; + } + + const char *start = text.c_str(); + const char *cur = start; + const char *best = start; + while (*cur) + { + const char *next = utf8_step(cur); + std::string candidate(start, next - start); + candidate += suffix; + if (!text_fits(font, candidate, max_width)) + { + break; + } + best = next; + cur = next; + } + + return std::string(start, best - start) + suffix; +} + +std::pair split_text_to_two_lines( + TTF_Font *font, + const std::string &text, + int max_width +) +{ + if (text.empty() || max_width <= 0) + { + return {}; + } + + if (text_fits(font, text, max_width)) + { + return { text, {} }; + } + + const char *start = text.c_str(); + const char *cur = start; + const char *best = start; + + while (*cur) + { + const char *next = utf8_step(cur); + std::string candidate(start, next - start); + if (!text_fits(font, candidate, max_width)) + { + break; + } + best = next; + cur = next; + } + + if (best == start) + { + return { truncate_text_to_width(font, text, max_width), {} }; + } + + std::string first_line(start, best - start); + std::string second_line = truncate_text_to_width(font, std::string(best), max_width); + return { first_line, second_line }; +} + +double preview_scale_to_fit(const SDL_Surface *surface, int max_width, int max_height) +{ + if (!surface || max_width <= 0 || max_height <= 0) + { + return 1.0; + } + + double width_scale = static_cast(max_width) / surface->w; + double height_scale = static_cast(max_height) / surface->h; + return std::min(1.0, std::min(width_scale, height_scale)); +} + +SDL_Rect preview_panel_rect() +{ + int width = std::clamp(SCREEN_WIDTH / 3, 120, 220); + return SDL_Rect { + static_cast(SCREEN_WIDTH - width - PREVIEW_PANEL_GAP), + static_cast(PREVIEW_PANEL_GAP), + static_cast(width), + static_cast(SCREEN_HEIGHT - PREVIEW_PANEL_GAP * 2) + }; +} + +SDL_Rect preview_image_rect(const SDL_Rect &panel, int line_height, int secondary_line_height) +{ + int header_height = secondary_line_height + LINE_PADDING; + int footer_height = line_height * 2 + secondary_line_height * 2 + PREVIEW_PANEL_PADDING; + return SDL_Rect { + static_cast(panel.x + PREVIEW_PANEL_PADDING), + static_cast(panel.y + PREVIEW_PANEL_PADDING + header_height), + static_cast(panel.w - PREVIEW_PANEL_PADDING * 2), + static_cast(std::max(0, panel.h - footer_height - header_height - PREVIEW_PANEL_PADDING * 2)) + }; +} + +std::string preview_cache_key(const std::filesystem::path &path, int line_height, int secondary_line_height) +{ + SDL_Rect panel = preview_panel_rect(); + SDL_Rect image_rect = preview_image_rect(panel, line_height, secondary_line_height); + return path.string() + "#" + + std::to_string(image_rect.w) + "x" + + std::to_string(image_rect.h); +} + +void render_text_line( + SDL_Surface *dest_surface, + TTF_Font *font, + const std::string &text, + const SDL_Color &fg_color, + const SDL_Color &bg_color, + Sint16 x, + Sint16 y +) +{ + if (text.empty()) + { + return; + } + + auto surface = surface_unique_ptr { + TTF_RenderUTF8_Shaded(font, text.c_str(), fg_color, bg_color) + }; + if (!surface) + { + return; + } + + SDL_Rect dest_rect = { x, y, 0, 0 }; + SDL_BlitSurface(surface.get(), NULL, dest_surface, &dest_rect); +} + +uint32_t secondary_font_size(uint32_t base_size) +{ + return std::max(10, base_size > 2 ? base_size - 2 : base_size); +} + +std::string load_progress_label(const std::filesystem::path &book_path, const StateStore &state_store) +{ + auto reader = create_doc_reader(book_path); + if (!reader || !reader->open()) + { + return {}; + } + + auto book_id = reader->get_id(); + if (book_id.empty()) + { + return {}; + } + + auto saved_address = state_store.read_book_address_from_disk(book_id); + if (!saved_address) + { + return "Unread"; + } + + uint32_t progress_percent = reader->get_global_progress_percent(*saved_address); + if (progress_percent >= 99) + { + return "Finished"; + } + if (progress_percent == 0) + { + return "Started"; + } + + return "Resume " + std::to_string(progress_percent) + "%"; +} + +} // namespace + +struct FSState +{ + std::filesystem::path path; + std::vector path_entries; + std::function on_file_selected; + std::function on_file_focus; + std::function on_view_focus; + std::function async; + + StateStore &state_store; + SystemStyling &styling; + const uint32_t styling_sub_id; + + bool needs_render = true; + bool is_done = false; + uint32_t cursor_pos = 0; + uint32_t scroll_pos = 0; + int line_height; + int secondary_line_height; + SortMode sort_mode = SortMode::TITLE; + bool index_started = false; + bool index_loading = false; + std::string pending_focus_entry_name; + uint32_t indexed_books = 0; + uint32_t total_books = 0; + + Throttled scroll_throttle; + + SDLImageCache preview_cache; + std::unordered_map preview_text_cache; + std::unordered_map progress_label_cache; + std::unordered_set missing_preview_paths; + std::filesystem::path focused_path; + bool preview_loading = false; + + std::mutex preview_mutex; + std::condition_variable preview_cv; + bool stop_preview_worker = false; + std::optional queued_preview_request; + uint64_t current_preview_request_id = 0; + std::thread preview_worker; + + std::mutex index_mutex; + std::condition_variable index_cv; + bool stop_index_worker = false; + std::optional queued_index_request; + uint64_t current_index_request_id = 0; + std::thread index_worker; + + FSState( + std::filesystem::path path, + StateStore &state_store, + SystemStyling &styling, + std::function async + ) + : path(std::move(path)) + , async(std::move(async)) + , state_store(state_store) + , styling(styling) + , styling_sub_id(styling.subscribe_to_changes([this, &styling](SystemStyling::ChangeId) { + needs_render = true; + line_height = detect_line_height( + styling.get_font_name(), + styling.get_font_size() + ) + LINE_PADDING; + secondary_line_height = detect_line_height( + styling.get_font_name(), + secondary_font_size(styling.get_font_size()) + ) + LINE_PADDING / 2; + })) + , line_height(detect_line_height( + styling.get_font_name(), + styling.get_font_size() + ) + LINE_PADDING) + , secondary_line_height(detect_line_height( + styling.get_font_name(), + secondary_font_size(styling.get_font_size()) + ) + LINE_PADDING / 2) + , scroll_throttle(250, 100) + { + } +}; + +namespace +{ + +void focus_menu_index(FSState *s, uint32_t new_cursor_pos); +void focus_menu_entry(FSState *s, const std::string &entry_name); + +uint32_t entry_height(const FSState *s) +{ + return s->line_height + s->secondary_line_height + ENTRY_GAP; +} + +uint32_t entry_content_height(const FSState *s) +{ + return s->line_height + s->secondary_line_height; +} + +uint32_t default_focus_index(const FSState *s) +{ + if (s->path_entries.empty()) + { + return 0; + } + + if (s->path_entries[0].name == ".." && s->path_entries.size() > 1) + { + return 1; + } + + return 0; +} + +uint32_t num_display_lines(const FSState *s) +{ + return std::max(1, SCREEN_HEIGHT / entry_height(s)); +} + +uint32_t excess_pxl_y(const FSState *s) +{ + return SCREEN_HEIGHT - num_display_lines(s) * entry_height(s); +} + +TTF_Font *secondary_font(const FSState *s) +{ + return cached_load_font( + s->styling.get_font_name(), + secondary_font_size(s->styling.get_font_size()) + ); +} + +std::string entry_cache_key(const std::filesystem::path &path) +{ + return path.string(); +} + +std::string entry_title_text(const FSState *s, const std::filesystem::path &path) +{ + auto info_it = s->preview_text_cache.find(entry_cache_key(path)); + if (info_it != s->preview_text_cache.end() && !info_it->second.title.empty()) + { + return info_it->second.title; + } + + return path.filename().string(); +} + +std::string entry_author_text(const FSState *s, const std::filesystem::path &path) +{ + auto info_it = s->preview_text_cache.find(entry_cache_key(path)); + if (info_it != s->preview_text_cache.end()) + { + return info_it->second.author; + } + + return {}; +} + +std::string normalized_sort_value(const std::string &value) +{ + return to_lower(value); +} + +uint32_t count_total_books(const std::vector &entries) +{ + uint32_t total = 0; + for (const auto &entry : entries) + { + if (!entry.is_dir) + { + ++total; + } + } + return total; +} + +void sort_path_entries(FSState *s) +{ + std::stable_sort(s->path_entries.begin(), s->path_entries.end(), [&s](const FSEntry &a, const FSEntry &b) { + if (a.name == ".." || b.name == "..") + { + return a.name == ".."; + } + + if (a.is_dir != b.is_dir) + { + return a.is_dir > b.is_dir; + } + + if (a.is_dir) + { + return strcasecmp(a.name.c_str(), b.name.c_str()) < 0; + } + + const auto a_path = s->path / a.name; + const auto b_path = s->path / b.name; + + const auto a_title = normalized_sort_value(entry_title_text(s, a_path)); + const auto b_title = normalized_sort_value(entry_title_text(s, b_path)); + const auto a_author = normalized_sort_value(entry_author_text(s, a_path)); + const auto b_author = normalized_sort_value(entry_author_text(s, b_path)); + const auto a_name = normalized_sort_value(a.name); + const auto b_name = normalized_sort_value(b.name); + + if (s->sort_mode == SortMode::AUTHOR) + { + const auto &a_primary = a_author.empty() ? a_title : a_author; + const auto &b_primary = b_author.empty() ? b_title : b_author; + if (a_primary != b_primary) + { + return a_primary < b_primary; + } + } + else if (a_title != b_title) + { + return a_title < b_title; + } + + if (a_title != b_title) + { + return a_title < b_title; + } + return a_name < b_name; + }); +} + +void apply_index_progress(const std::shared_ptr &s, IndexProgress progress) +{ + if (progress.request_id != s->current_index_request_id) + { + return; + } + + if (!progress.book_path.empty()) + { + s->preview_text_cache[progress.book_path.string()] = PreviewTextInfo { + progress.title, + progress.author + }; + s->progress_label_cache[progress.book_path.string()] = progress.progress_label; + } + + s->indexed_books = progress.indexed_books; + s->total_books = progress.total_books; + + if (progress.finished) + { + s->index_loading = false; + sort_path_entries(s.get()); + + if (!s->pending_focus_entry_name.empty()) + { + focus_menu_entry(s.get(), s->pending_focus_entry_name); + } + else + { + focus_menu_index(s.get(), default_focus_index(s.get())); + } + s->pending_focus_entry_name.clear(); + } + + s->needs_render = true; +} + +void apply_preview_result(const std::shared_ptr &s, PreviewLoadResult result) +{ + if (result.request_id != s->current_preview_request_id || + result.book_path != s->focused_path) + { + return; + } + + s->preview_loading = false; + s->preview_text_cache[result.book_path.string()] = PreviewTextInfo { + result.title, + result.author + }; + if (!result.progress_label.empty()) + { + s->progress_label_cache[result.book_path.string()] = result.progress_label; + } + + if (result.data.empty()) + { + s->missing_preview_paths.insert(result.book_path.string()); + s->needs_render = true; + return; + } + + auto image_format = image_format_for_preview(result); + if (image_format.empty()) + { + s->missing_preview_paths.insert(result.book_path.string()); + s->needs_render = true; + return; + } + + auto surface = load_surface_from_ptr( + result.data.data(), + result.data.size(), + image_format, + get_render_surface_format() + ); + if (!surface) + { + s->missing_preview_paths.insert(result.book_path.string()); + s->needs_render = true; + return; + } + + SDL_Rect panel = preview_panel_rect(); + SDL_Rect image_rect = preview_image_rect(panel, s->line_height, s->secondary_line_height); + double scale = preview_scale_to_fit(surface.get(), image_rect.w, image_rect.h); + s->preview_cache.put_image( + preview_cache_key(result.book_path, s->line_height, s->secondary_line_height), + scale < 1.0 ? + surface_unique_ptr { zoomSurface(surface.get(), scale, scale, 1) } : + std::move(surface) + ); + s->needs_render = true; +} + +void index_worker_main(std::weak_ptr weak_state) +{ + while (true) + { + auto s = weak_state.lock(); + if (!s) + { + return; + } + + IndexRequest request; + { + std::unique_lock lock(s->index_mutex); + s->index_cv.wait(lock, [&s]() { + return s->stop_index_worker || s->queued_index_request.has_value(); + }); + + if (s->stop_index_worker) + { + return; + } + + request = *s->queued_index_request; + s->queued_index_request.reset(); + } + + uint32_t indexed_books = 0; + uint32_t total_books = count_total_books(request.path_entries); + + for (const auto &entry : request.path_entries) + { + if (entry.is_dir) + { + continue; + } + + const auto book_path = request.directory_path / entry.name; + IndexProgress progress; + progress.request_id = request.request_id; + progress.book_path = book_path; + progress.total_books = total_books; + + if (path_is_epub(book_path)) + { + EpubCover metadata; + if (epub_load_book_metadata(book_path, metadata)) + { + progress.title = metadata.title; + progress.author = metadata.author; + } + } + + progress.indexed_books = ++indexed_books; + + s->async([weak_state, progress = std::move(progress)]() mutable { + if (auto state = weak_state.lock()) + { + apply_index_progress(state, std::move(progress)); + } + }); + } + + IndexProgress finished_progress; + finished_progress.request_id = request.request_id; + finished_progress.total_books = total_books; + finished_progress.indexed_books = indexed_books; + finished_progress.finished = true; + + s->async([weak_state, finished_progress = std::move(finished_progress)]() mutable { + if (auto state = weak_state.lock()) + { + apply_index_progress(state, std::move(finished_progress)); + } + }); + } +} + +void preview_worker_main(std::weak_ptr weak_state) { - std::filesystem::path path; - std::vector path_entries; - std::function on_file_selected; - std::function on_file_focus; - std::function on_view_focus; + while (true) + { + auto s = weak_state.lock(); + if (!s) + { + return; + } + + PreviewRequest request; + { + std::unique_lock lock(s->preview_mutex); + s->preview_cv.wait(lock, [&s]() { + return s->stop_preview_worker || s->queued_preview_request.has_value(); + }); + + if (s->stop_preview_worker) + { + return; + } + + request = *s->queued_preview_request; + s->queued_preview_request.reset(); + } + + PreviewLoadResult result; + result.request_id = request.request_id; + result.book_path = request.book_path; + + EpubCover cover; + if (epub_load_preview_info(request.book_path, cover)) + { + result.title = cover.title; + result.author = cover.author; + result.progress_label = load_progress_label(request.book_path, s->state_store); + result.cover_path = cover.href_absolute; + result.media_type = cover.media_type; + result.data = std::move(cover.data); + } + else + { + result.progress_label = load_progress_label(request.book_path, s->state_store); + } + + s->async([weak_state, result = std::move(result)]() mutable { + if (auto state = weak_state.lock()) + { + apply_preview_result(state, std::move(result)); + } + }); + } +} - SelectionMenu menu; +void cancel_preview_request(FSState *s) +{ + { + std::lock_guard lock(s->preview_mutex); + ++s->current_preview_request_id; + s->queued_preview_request.reset(); + } + s->preview_loading = false; +} - FSState(std::filesystem::path path, SystemStyling &styling) - : path(path), - menu(styling) +void queue_index_request(FSState *s) +{ { + std::lock_guard lock(s->index_mutex); + s->queued_index_request = IndexRequest { + ++s->current_index_request_id, + s->path, + s->path_entries + }; } -}; -namespace { + s->index_started = true; + s->index_loading = s->total_books > 0; + s->indexed_books = 0; + s->index_cv.notify_one(); +} + +void begin_indexing_directory(FSState *s) +{ + if (s->index_loading || s->index_started) + { + return; + } + + cancel_preview_request(s); + s->focused_path.clear(); + + if (s->total_books == 0) + { + sort_path_entries(s); + if (!s->pending_focus_entry_name.empty()) + { + focus_menu_entry(s, s->pending_focus_entry_name); + s->pending_focus_entry_name.clear(); + } + else + { + focus_menu_index(s, default_focus_index(s)); + } + s->needs_render = true; + return; + } + + queue_index_request(s); + s->needs_render = true; +} + +void queue_preview_request(FSState *s, const std::filesystem::path &path) +{ + { + std::lock_guard lock(s->preview_mutex); + s->queued_preview_request = PreviewRequest { + ++s->current_preview_request_id, + path + }; + } + s->preview_loading = true; + s->preview_cv.notify_one(); +} void refresh_path_entries(FSState *s) { + cancel_preview_request(s); s->path_entries.clear(); if (s->path.has_parent_path() && s->path != s->path.root_path()) { s->path_entries.push_back(FSEntry::directory("..")); } - for (const auto &entry : directory_listing(s->path)) { - if (entry.is_dir || file_type_is_supported(entry.name)) { + for (const auto &entry : directory_listing(s->path)) + { + if (entry.is_dir || file_type_is_supported(entry.name)) + { s->path_entries.push_back(entry); } } - std::vector menu_entries; - for (const auto &entry : s->path_entries) + s->total_books = count_total_books(s->path_entries); + s->index_started = false; + s->index_loading = false; + s->cursor_pos = 0; + s->scroll_pos = 0; + s->focused_path.clear(); + s->indexed_books = 0; +} + +void toggle_sort_mode(FSState *s) +{ + s->sort_mode = ( + s->sort_mode == SortMode::TITLE ? SortMode::AUTHOR : SortMode::TITLE + ); + + if (!s->index_loading && s->index_started) { - menu_entries.push_back(entry.name); + const auto focused_name = ( + s->path_entries.empty() ? std::string() : s->path_entries[s->cursor_pos].name + ); + sort_path_entries(s); + if (!focused_name.empty()) + { + focus_menu_entry(s, focused_name); + } + else + { + focus_menu_index(s, 0); + } } - s->menu.set_entries(menu_entries); + + s->needs_render = true; } -void on_menu_entry_selected(FSState *s, uint32_t menu_index) +void focus_menu_index(FSState *s, uint32_t new_cursor_pos) { if (s->path_entries.empty()) { + s->cursor_pos = 0; + s->scroll_pos = 0; + s->focused_path.clear(); + cancel_preview_request(s); + s->needs_render = true; return; } - const FSEntry &entry = s->path_entries[menu_index]; + if (new_cursor_pos >= s->path_entries.size()) + { + new_cursor_pos = 0; + } + + s->cursor_pos = new_cursor_pos; + + int lines = num_display_lines(s); + int num_entries = s->path_entries.size(); + s->scroll_pos = std::max( + 0, + std::min( + num_entries - lines, + static_cast(new_cursor_pos) - lines / 4 - 1 + ) + ); + + const auto &entry = s->path_entries[s->cursor_pos]; + s->focused_path = s->path / entry.name; + if (s->on_file_focus) + { + s->on_file_focus(s->focused_path); + } + if (entry.is_dir) { - if (entry.name == "..") - { - std::string highlight_name = s->path.filename(); + cancel_preview_request(s); + } + else if (file_type_is_supported(s->focused_path)) + { + const bool cover_known = ( + !path_is_epub(s->focused_path) || + s->preview_cache.get_image(preview_cache_key(s->focused_path, s->line_height, s->secondary_line_height)) || + s->missing_preview_paths.count(s->focused_path.string()) + ); + const bool progress_known = s->progress_label_cache.count(s->focused_path.string()); - s->path = s->path.parent_path(); - refresh_path_entries(s); - s->menu.set_cursor_pos(highlight_name); + if (cover_known && progress_known) + { + cancel_preview_request(s); } else { - // Go down a directory - s->path /= entry.name; - refresh_path_entries(s); - s->menu.set_cursor_pos(1); // get past ".." entry + queue_preview_request(s, s->focused_path); } } else { - if (s->on_file_selected) + cancel_preview_request(s); + } + + s->needs_render = true; +} + +void focus_menu_entry(FSState *s, const std::string &entry_name) +{ + for (uint32_t i = 0; i < s->path_entries.size(); ++i) + { + if (s->path_entries[i].name == entry_name) { - s->on_file_selected(s->path / entry.name); + focus_menu_index(s, i); + return; } } + + focus_menu_index(s, 0); } -void on_menu_entry_focused(FSState *s, uint32_t menu_index) +void on_menu_entry_selected(FSState *s) { - if (!s->path_entries.empty() && s->on_file_focus) + if (s->index_loading) + { + return; + } + + if (s->path_entries.empty()) + { + return; + } + + const FSEntry &entry = s->path_entries[s->cursor_pos]; + if (entry.is_dir) + { + if (entry.name == "..") + { + std::string highlight_name = s->path.filename().string(); + + s->path = s->path.parent_path(); + refresh_path_entries(s); + s->pending_focus_entry_name = highlight_name; + begin_indexing_directory(s); + } + else + { + s->path /= entry.name; + refresh_path_entries(s); + s->pending_focus_entry_name.clear(); + begin_indexing_directory(s); + } + } + else if (s->on_file_selected) { - const auto &entry = s->path_entries[menu_index]; - s->on_file_focus(s->path / entry.name); + s->on_file_selected(s->path / entry.name); } } @@ -100,11 +1025,9 @@ std::filesystem::path sanitize_starting_path(std::filesystem::path path) if (path.has_parent_path()) { - // get the directory component path = path.parent_path(); } - // make sure path exists while (!std::filesystem::is_directory(path)) { std::cerr << "Directory " << path << " does not exist" << std::endl; @@ -122,55 +1045,524 @@ std::filesystem::path sanitize_starting_path(std::filesystem::path path) return path; } -} // namespace +std::string preview_status_text(const FSState *s) +{ + if (s->focused_path.empty()) + { + return "Browse your library"; + } -FileSelector::FileSelector(std::filesystem::path path, SystemStyling &styling) - : state(std::make_unique( - sanitize_starting_path(path), - styling - )) + if (s->preview_loading) + { + return "Loading details..."; + } + if (s->preview_cache.get_image(preview_cache_key(s->focused_path, s->line_height, s->secondary_line_height))) + { + return "Embedded cover"; + } + if (s->missing_preview_paths.count(s->focused_path.string())) + { + return "No embedded cover"; + } + + auto entry_it = std::find_if( + s->path_entries.begin(), + s->path_entries.end(), + [&s](const auto &entry) { + return s->focused_path == s->path / entry.name; + } + ); + if (entry_it != s->path_entries.end() && entry_it->is_dir) + { + return "Folder"; + } + if (path_is_epub(s->focused_path)) + { + return "Cover preview unavailable"; + } + if (file_type_is_supported(s->focused_path)) + { + return "Preview available for EPUB"; + } + return {}; +} + +std::string preview_title_text(const FSState *s) { - state->menu.set_on_selection([this](uint32_t menu_index) { - on_menu_entry_selected(this->state.get(), menu_index); - }); + if (s->focused_path.empty()) + { + return "Browse your library"; + } - state->menu.set_on_focus([this](uint32_t menu_index) { - on_menu_entry_focused(this->state.get(), menu_index); - }); + auto info_it = s->preview_text_cache.find(s->focused_path.string()); + if (info_it != s->preview_text_cache.end() && !info_it->second.title.empty()) + { + return info_it->second.title; + } - refresh_path_entries(state.get()); - if (path.has_filename()) + return s->focused_path.filename().string(); +} + +std::string preview_subtitle_text(const FSState *s) +{ + if (s->focused_path.empty()) { - state->menu.set_cursor_pos(path.filename()); + return {}; } - else + + auto info_it = s->preview_text_cache.find(s->focused_path.string()); + if (info_it != s->preview_text_cache.end() && !info_it->second.author.empty()) + { + return info_it->second.author; + } + + return preview_status_text(s); +} + +std::string preview_progress_text(const FSState *s) +{ + if (s->focused_path.empty()) + { + return {}; + } + + auto it = s->progress_label_cache.find(s->focused_path.string()); + if (it != s->progress_label_cache.end() && !it->second.empty()) + { + return it->second; + } + + if (file_type_is_supported(s->focused_path) && !s->preview_loading) + { + return "Unread"; + } + + return {}; +} + +std::string preview_sort_text(const FSState *s) +{ + return s->sort_mode == SortMode::AUTHOR ? "Sort: Author" : "Sort: Title"; +} + +void render_loading_message(FSState *s, SDL_Surface *dest_surface) +{ + TTF_Font *font = s->styling.get_loaded_font(); + const auto &theme = s->styling.get_loaded_color_theme(); + std::string text = "Loading " + std::to_string(s->indexed_books) + + " of " + std::to_string(s->total_books) + " books..."; + + int text_width = 0; + int text_height = 0; + TTF_SizeUTF8(font, text.c_str(), &text_width, &text_height); + + Sint16 x = std::max(LINE_PADDING, (static_cast(SCREEN_WIDTH) - text_width) / 2); + Sint16 y = std::max(LINE_PADDING, (SCREEN_HEIGHT - text_height) / 2); + render_text_line( + dest_surface, + font, + text, + theme.main_text, + theme.background, + x, + y + ); +} + +void render_preview_panel(FSState *s, SDL_Surface *dest_surface) +{ + TTF_Font *font = s->styling.get_loaded_font(); + TTF_Font *small_font = secondary_font(s); + const auto &theme = s->styling.get_loaded_color_theme(); + const auto &bg_color = theme.background; + + SDL_Rect panel = preview_panel_rect(); + + SDL_FillRect( + dest_surface, + &panel, + SDL_MapRGB(dest_surface->format, theme.main_text.r, theme.main_text.g, theme.main_text.b) + ); + + SDL_Rect panel_inner = panel; + panel_inner.x += PREVIEW_BORDER_WIDTH; + panel_inner.y += PREVIEW_BORDER_WIDTH; + panel_inner.w -= PREVIEW_BORDER_WIDTH * 2; + panel_inner.h -= PREVIEW_BORDER_WIDTH * 2; + SDL_FillRect( + dest_surface, + &panel_inner, + SDL_MapRGB(dest_surface->format, bg_color.r, bg_color.g, bg_color.b) + ); + + SDL_Rect image_rect = preview_image_rect(panel_inner, s->line_height, s->secondary_line_height); + render_text_line( + dest_surface, + small_font, + preview_sort_text(s), + theme.secondary_text, + bg_color, + panel_inner.x + PREVIEW_PANEL_PADDING, + panel_inner.y + PREVIEW_PANEL_PADDING + ); + + if (auto *cover = s->preview_cache.get_image(preview_cache_key(s->focused_path, s->line_height, s->secondary_line_height))) + { + SDL_Rect dest_rect = { + static_cast(image_rect.x + (image_rect.w - cover->w) / 2), + static_cast(image_rect.y + (image_rect.h - cover->h) / 2), + 0, + 0 + }; + SDL_BlitSurface(cover, NULL, dest_surface, &dest_rect); + } + + const int text_width = panel_inner.w - PREVIEW_PANEL_PADDING * 2; + const Sint16 title_y = panel_inner.y + panel_inner.h - + (s->line_height * 2 + s->secondary_line_height * 2); + const auto [title_line_1, title_line_2] = split_text_to_two_lines( + font, + preview_title_text(s), + text_width + ); + render_text_line( + dest_surface, + font, + title_line_1, + theme.main_text, + bg_color, + panel_inner.x + PREVIEW_PANEL_PADDING, + title_y + ); + render_text_line( + dest_surface, + font, + title_line_2, + theme.main_text, + bg_color, + panel_inner.x + PREVIEW_PANEL_PADDING, + title_y + s->line_height + ); + + auto subtitle = truncate_text_to_width( + small_font, + preview_subtitle_text(s), + text_width + ); + render_text_line( + dest_surface, + small_font, + subtitle, + theme.secondary_text, + bg_color, + panel_inner.x + PREVIEW_PANEL_PADDING, + title_y + s->line_height * 2 + ); + + auto progress = truncate_text_to_width( + small_font, + preview_progress_text(s), + text_width + ); + render_text_line( + dest_surface, + small_font, + progress, + theme.secondary_text, + bg_color, + panel_inner.x + PREVIEW_PANEL_PADDING, + title_y + s->line_height * 2 + s->secondary_line_height + ); +} + +void render_file_list(FSState *s, SDL_Surface *dest_surface) +{ + TTF_Font *font = s->styling.get_loaded_font(); + TTF_Font *small_font = secondary_font(s); + const auto &theme = s->styling.get_loaded_color_theme(); + const SDL_Color &fg_color = theme.main_text; + const SDL_Color &secondary_fg_color = theme.secondary_text; + const SDL_Color &bg_color = theme.background; + const SDL_Color &hl_bg_color = theme.highlight_background; + const SDL_Color &hl_text_color = theme.highlight_text; + + SDL_Rect panel = preview_panel_rect(); + int list_width = panel.x - PREVIEW_PANEL_GAP; + + Sint16 x = LINE_PADDING; + Sint16 y = excess_pxl_y(s) / 2; + + uint32_t num_lines = num_display_lines(s); + for (uint32_t i = 0; i < num_lines; ++i) { - state->menu.set_cursor_pos(1); // get past ".." entry + uint32_t global_i = i + s->scroll_pos; + if (global_i >= s->path_entries.size()) + { + break; + } + + const auto &entry = s->path_entries[global_i]; + const bool is_highlighted = (global_i == s->cursor_pos); + + if (is_highlighted) + { + SDL_Rect rect = {0, y, static_cast(list_width), static_cast(entry_content_height(s))}; + SDL_FillRect( + dest_surface, + &rect, + SDL_MapRGB(dest_surface->format, hl_bg_color.r, hl_bg_color.g, hl_bg_color.b) + ); + } + + const auto entry_path = s->path / entry.name; + const auto author = entry.is_dir ? std::string() : entry_author_text(s, entry_path); + const bool emphasize_author = ( + !is_highlighted && + !entry.is_dir && + s->sort_mode == SortMode::AUTHOR && + !author.empty() + ); + const SDL_Color &title_color = ( + is_highlighted ? hl_text_color : + emphasize_author ? secondary_fg_color : + fg_color + ); + const SDL_Color &author_color = ( + is_highlighted ? hl_text_color : + emphasize_author ? fg_color : + secondary_fg_color + ); + auto display_name = truncate_text_to_width( + font, + entry_title_text(s, entry_path), + list_width - LINE_PADDING * 2 + ); + render_text_line( + dest_surface, + font, + display_name, + title_color, + is_highlighted ? hl_bg_color : bg_color, + x, + y + LINE_PADDING / 2 + ); + + auto subtitle = truncate_text_to_width( + small_font, + author, + list_width - LINE_PADDING * 2 + ); + render_text_line( + dest_surface, + small_font, + subtitle, + author_color, + is_highlighted ? hl_bg_color : bg_color, + x, + y + s->line_height + ); + + y += entry_height(s); } } +} // namespace + +FileSelector::FileSelector( + std::filesystem::path path, + StateStore &state_store, + SystemStyling &styling, + std::function async +) + : state(std::shared_ptr(new FSState( + sanitize_starting_path(path), + state_store, + styling, + std::move(async) + ))) +{ + refresh_path_entries(state.get()); + state->pending_focus_entry_name = path.has_filename() ? path.filename().string() : std::string(); + + state->preview_worker = std::thread(preview_worker_main, std::weak_ptr(state)); + state->index_worker = std::thread(index_worker_main, std::weak_ptr(state)); +} + FileSelector::~FileSelector() { + { + std::lock_guard lock(state->preview_mutex); + state->stop_preview_worker = true; + state->queued_preview_request.reset(); + } + state->preview_cv.notify_one(); + if (state->preview_worker.joinable()) + { + state->preview_worker.join(); + } + + { + std::lock_guard lock(state->index_mutex); + state->stop_index_worker = true; + state->queued_index_request.reset(); + } + state->index_cv.notify_one(); + if (state->index_worker.joinable()) + { + state->index_worker.join(); + } + + state->styling.unsubscribe_from_changes(state->styling_sub_id); } bool FileSelector::render(SDL_Surface *dest_surface, bool force_render) { - return state->menu.render(dest_surface, force_render); + if (!state->needs_render && !force_render) + { + return false; + } + state->needs_render = false; + + const auto &bg_color = state->styling.get_loaded_color_theme().background; + SDL_Rect rect = {0, 0, SCREEN_WIDTH, SCREEN_HEIGHT}; + SDL_FillRect( + dest_surface, + &rect, + SDL_MapRGB(dest_surface->format, bg_color.r, bg_color.g, bg_color.b) + ); + + if (state->index_loading) + { + render_loading_message(state.get(), dest_surface); + } + else + { + render_file_list(state.get(), dest_surface); + render_preview_panel(state.get(), dest_surface); + } + return true; } bool FileSelector::is_done() { - return state->menu.is_done(); + return state->is_done; } void FileSelector::on_keypress(SDLKey key) { - state->menu.on_keypress(key); + if (state->index_loading) + { + switch (key) + { + case SW_BTN_Y: + toggle_sort_mode(state.get()); + break; + case SW_BTN_B: + state->is_done = true; + break; + default: + break; + } + return; + } + + switch (key) + { + case SW_BTN_UP: + if (state->cursor_pos > 0) + { + focus_menu_index(state.get(), state->cursor_pos - 1); + state->scroll_pos = std::min(state->scroll_pos, state->cursor_pos); + } + break; + case SW_BTN_DOWN: + if (!state->path_entries.empty() && state->cursor_pos < state->path_entries.size() - 1) + { + focus_menu_index(state.get(), state->cursor_pos + 1); + if (state->cursor_pos >= state->scroll_pos + num_display_lines(state.get())) + { + state->scroll_pos = state->cursor_pos - num_display_lines(state.get()) + 1; + } + } + break; + case SW_BTN_L1: + case SW_BTN_R1: + case SW_BTN_L2: + case SW_BTN_R2: + { + auto [l_key, r_key] = get_shoulder_keymap_lr( + state->styling.get_shoulder_keymap() + ); + + if (key == l_key) + { + key = SW_BTN_LEFT; + } + else if (key == r_key) + { + key = SW_BTN_RIGHT; + } + } + // fallthrough + case SW_BTN_LEFT: + if (!state->path_entries.empty()) + { + uint32_t step = std::max(1, num_display_lines(state.get()) / 2); + focus_menu_index( + state.get(), + state->cursor_pos <= step ? 0 : state->cursor_pos - step + ); + state->scroll_pos = std::min(state->scroll_pos, state->cursor_pos); + } + break; + case SW_BTN_RIGHT: + if (!state->path_entries.empty()) + { + uint32_t step = std::max(1, num_display_lines(state.get()) / 2); + focus_menu_index( + state.get(), + std::min(state->cursor_pos + step, state->path_entries.size() - 1) + ); + if (state->cursor_pos >= state->scroll_pos + num_display_lines(state.get())) + { + state->scroll_pos = state->cursor_pos - num_display_lines(state.get()) + 1; + } + } + break; + case SW_BTN_A: + on_menu_entry_selected(state.get()); + break; + case SW_BTN_Y: + toggle_sort_mode(state.get()); + break; + case SW_BTN_B: + state->is_done = true; + break; + default: + break; + } } void FileSelector::on_keyheld(SDLKey key, uint32_t held_time_ms) { - state->menu.on_keyheld(key, held_time_ms); + switch (key) + { + case SW_BTN_UP: + case SW_BTN_DOWN: + case SW_BTN_LEFT: + case SW_BTN_RIGHT: + case SW_BTN_L1: + case SW_BTN_R1: + case SW_BTN_L2: + case SW_BTN_R2: + if (state->scroll_throttle(held_time_ms)) + { + on_keypress(key); + } + break; + default: + break; + } } void FileSelector::on_focus() @@ -179,6 +1571,8 @@ void FileSelector::on_focus() { state->on_view_focus(); } + + begin_indexing_directory(state.get()); } void FileSelector::set_on_file_selected(std::function callback) diff --git a/src/reader/views/file_selector.h b/src/reader/views/file_selector.h index 846337c..3695d5a 100644 --- a/src/reader/views/file_selector.h +++ b/src/reader/views/file_selector.h @@ -2,6 +2,7 @@ #define FILE_SELECTOR_H_ #include "reader/view.h" +#include "util/task_queue.h" #include @@ -11,15 +12,21 @@ #include struct FSState; +class StateStore; struct SystemStyling; class FileSelector: public View { - std::unique_ptr state; + std::shared_ptr state; public: // Expects to receive a path to a file, or directory with trailing separator. - FileSelector(std::filesystem::path path, SystemStyling &styling); + FileSelector( + std::filesystem::path path, + StateStore &state_store, + SystemStyling &styling, + std::function async + ); virtual ~FileSelector(); bool render(SDL_Surface *dest_surface, bool force_render) override; diff --git a/src/util/sdl_image_cache.cpp b/src/util/sdl_image_cache.cpp index b7db498..3d178ea 100644 --- a/src/util/sdl_image_cache.cpp +++ b/src/util/sdl_image_cache.cpp @@ -26,11 +26,11 @@ void SDLImageCache::put_image(const std::string &key, surface_unique_ptr image) total_size_bytes += surface_size; } -SDL_Surface *SDLImageCache::get_image(const std::string &key) +SDL_Surface *SDLImageCache::get_image(const std::string &key) const { if (!cache.has(key)) { return nullptr; } - return cache[key].get(); + return const_cast &>(cache)[key].get(); } diff --git a/src/util/sdl_image_cache.h b/src/util/sdl_image_cache.h index 815aa7f..c419987 100644 --- a/src/util/sdl_image_cache.h +++ b/src/util/sdl_image_cache.h @@ -15,7 +15,7 @@ class SDLImageCache public: void put_image(const std::string &key, surface_unique_ptr image); - SDL_Surface *get_image(const std::string &key); + SDL_Surface *get_image(const std::string &key) const; }; #endif diff --git a/src/util/task_queue.cpp b/src/util/task_queue.cpp index 0ddf54d..ca5520b 100644 --- a/src/util/task_queue.cpp +++ b/src/util/task_queue.cpp @@ -11,16 +11,28 @@ TaskQueue::~TaskQueue() void TaskQueue::submit(task_func task) { - queue.push(task); + std::lock_guard lock(mutex); + queue.push(std::move(task)); } bool TaskQueue::drain() { bool ran_task = false; - while (!queue.empty()) + while (true) { - queue.front()(); - queue.pop(); + task_func task; + { + std::lock_guard lock(mutex); + if (queue.empty()) + { + break; + } + + task = std::move(queue.front()); + queue.pop(); + } + + task(); ran_task = true; } diff --git a/src/util/task_queue.h b/src/util/task_queue.h index 4e642dd..2e39fe2 100644 --- a/src/util/task_queue.h +++ b/src/util/task_queue.h @@ -2,6 +2,7 @@ #define TASK_QUEUE_H_ #include +#include #include using task_func = typename std::function; @@ -9,6 +10,7 @@ using task_func = typename std::function; class TaskQueue { std::queue queue; + std::mutex mutex; public: