Compare commits

..

5 Commits

Author SHA1 Message Date
CaptainFrito
a1843e1196 feat: UI themes, Lyra 2026-02-01 21:58:55 +07:00
Uri Tauber
e5c0ddc9fa
feat: Debugging monitor script (#555)
Some checks are pending
CI / build (push) Waiting to run
## Summary

* **What is the goal of this PR?**
Add a debugging script to help developers monitor the ESP32 serial port
directly from a PC.

* **What changes are included?**
Added a new script: scripts/debugging_monitor.py

## Additional Context

While working on a new Crosspoint-Reader feature, it quickly became
clear that watching the ESP32 serial output without any visual cues was
inconvenient and easy to mess up.

This script improves the debugging experience by reading data from the
serial port and providing:

1. A timestamp prefix for every log line (instead of milliseconds since
power-up)
2. Color-coded output for different message types
3. A secondary window displaying a live graph of RAM usage, which is
especially useful for tracking the memory impact of new features

<img width="1916" height="1049" alt="Screenshot_20260126_183811"
src="https://github.com/user-attachments/assets/6291887f-ac17-43ac-9e43-f5dec8a7097e"
/>

---

### AI Usage

Did you use AI tools to help write this code? _**< PARTIALLY >**_
I wrote the initial version of the script. Gemini was used to help add
the Matplotlib-based graphing and threading logic.
2026-02-01 22:53:20 +11:00
Arthur Tazhitdinov
b1dcb7733b
fix: truncating chapter titles using UTF-8 safe function (#599)
## Summary

* Truncating chapter titles using utf8 safe functions (Cyrillic titles
were split mid codepoint)
* refactoring of lib/Utf8

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< PARTIALLY >**_
2026-02-01 22:23:48 +11:00
Arthur Tazhitdinov
0d82b03981
fix: don't wake up after USB connect (#644)
## Summary

* fixes problem that if short power button press is enabled, connecting
device to usb leads to waking up
2026-02-01 22:19:33 +11:00
Dave Allie
5a97334ace
Revert "fix: don't wake up after USB connect" (#643)
Reverts crosspoint-reader/crosspoint-reader#576

Causing a boot loop on master
2026-02-01 21:35:25 +11:00
28 changed files with 457 additions and 183 deletions

View File

@ -95,6 +95,20 @@ Connect your Xteink X4 to your computer via USB-C and run the following command.
```sh ```sh
pio run --target upload pio run --target upload
``` ```
### Debugging
After flashing the new features, its recommended to capture detailed logs from the serial port.
First, make sure all required Python packages are installed:
```python
python3 -m pip install serial colorama matplotlib
```
after that run the script:
```sh
python3 scripts/debugging_monitor.py
```
This was tested on Debian and should work on most Linux systems. Minor adjustments may be required for Windows or macOS.
## Internals ## Internals

View File

@ -428,6 +428,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false; return false;
} }
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; } std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
bool Epub::generateThumbBmp(int height) const { bool Epub::generateThumbBmp(int height) const {

View File

@ -47,6 +47,7 @@ class Epub {
const std::string& getLanguage() const; const std::string& getLanguage() const;
std::string getCoverBmpPath(bool cropped = false) const; std::string getCoverBmpPath(bool cropped = false) const;
bool generateCoverBmp(bool cropped = false) const; bool generateCoverBmp(bool cropped = false) const;
std::string getThumbBmpPath() const;
std::string getThumbBmpPath(int height) const; std::string getThumbBmpPath(int height) const;
bool generateThumbBmp(int height) const; bool generateThumbBmp(int height) const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,

View File

@ -247,10 +247,10 @@ static constexpr int matrixSize = 4;
static constexpr int matrixLevels = matrixSize * matrixSize; static constexpr int matrixLevels = matrixSize * matrixSize;
void GfxRenderer::drawPixelDither(const int x, const int y, Color color) const { void GfxRenderer::drawPixelDither(const int x, const int y, Color color) const {
if (color == COLOR_CLEAR) { if (color == Color::Clear) {
} else if (color == COLOR_BLACK) { } else if (color == Color::Black) {
drawPixel(x, y, true); drawPixel(x, y, true);
} else if (color == COLOR_WHITE) { } else if (color == Color::White) {
drawPixel(x, y, false); drawPixel(x, y, false);
} else { } else {
// Use dithering // Use dithering
@ -269,10 +269,10 @@ void GfxRenderer::drawPixelDither(const int x, const int y, Color color) const {
// Use Bayer matrix 4x4 dithering to fill the rectangle with a grey level // Use Bayer matrix 4x4 dithering to fill the rectangle with a grey level
void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const { void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const {
if (color == COLOR_CLEAR) { if (color == Color::Clear) {
} else if (color == COLOR_BLACK) { } else if (color == Color::Black) {
fillRect(x, y, width, height, true); fillRect(x, y, width, height, true);
} else if (color == COLOR_WHITE) { } else if (color == Color::White) {
fillRect(x, y, width, height, false); fillRect(x, y, width, height, false);
} else { } else {
for (int fillY = y; fillY < y + height; fillY++) { for (int fillY = y; fillY < y + height; fillY++) {
@ -628,13 +628,21 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
const EpdFontFamily::Style style) const { const EpdFontFamily::Style style) const {
if (!text || maxWidth <= 0) return "";
std::string item = text; std::string item = text;
int itemWidth = getTextWidth(fontId, item.c_str(), style); const char* ellipsis = "...";
while (itemWidth > maxWidth && item.length() > 8) { int textWidth = getTextWidth(fontId, item.c_str(), style);
item.replace(item.length() - 5, 5, "..."); if (textWidth <= maxWidth) {
itemWidth = getTextWidth(fontId, item.c_str(), style); // Text fits, return as is
return item;
} }
return item;
while (!item.empty() && getTextWidth(fontId, (item + ellipsis).c_str(), style) >= maxWidth) {
utf8RemoveLastChar(item);
}
return item.empty() ? ellipsis : item + ellipsis;
} }
// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation // Note: Internal driver treats screen in command orientation; this library exposes a logical orientation

View File

@ -9,13 +9,7 @@
// Color representation: uint8_t mapped to 4x4 Bayer matrix dithering levels // Color representation: uint8_t mapped to 4x4 Bayer matrix dithering levels
// 0 = transparent, 1-16 = gray levels (white to black) // 0 = transparent, 1-16 = gray levels (white to black)
using Color = uint8_t; enum Color : uint8_t { Clear = 0x00, White = 0x01, LightGray = 0x05, DarkGray = 0x0A, Black = 0x10 };
constexpr Color COLOR_CLEAR = 0x00;
constexpr Color COLOR_WHITE = 0x01;
constexpr Color COLOR_LIGHT_GRAY = 0x05;
constexpr Color COLOR_DARK_GRAY = 0x0A;
constexpr Color COLOR_BLACK = 0x10;
class GfxRenderer { class GfxRenderer {
public: public:

View File

@ -29,3 +29,20 @@ uint32_t utf8NextCodepoint(const unsigned char** string) {
return cp; return cp;
} }
size_t utf8RemoveLastChar(std::string& str) {
if (str.empty()) return 0;
size_t pos = str.size() - 1;
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
--pos;
}
str.resize(pos);
return pos;
}
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, const size_t numChars) {
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
utf8RemoveLastChar(str);
}
}

View File

@ -1,7 +1,11 @@
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <string>
#define REPLACEMENT_GLYPH 0xFFFD #define REPLACEMENT_GLYPH 0xFFFD
uint32_t utf8NextCodepoint(const unsigned char** string); uint32_t utf8NextCodepoint(const unsigned char** string);
// Remove the last UTF-8 codepoint from a std::string and return the new size.
size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 codepoints from the end.
void utf8TruncateChars(std::string& str, size_t numChars);

View File

@ -301,6 +301,7 @@ bool Xtc::generateCoverBmp() const {
return true; return true;
} }
std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
std::string Xtc::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; } std::string Xtc::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
bool Xtc::generateThumbBmp(int height) const { bool Xtc::generateThumbBmp(int height) const {

View File

@ -64,6 +64,7 @@ class Xtc {
std::string getCoverBmpPath() const; std::string getCoverBmpPath() const;
bool generateCoverBmp() const; bool generateCoverBmp() const;
// Thumbnail support (for Continue Reading card) // Thumbnail support (for Continue Reading card)
std::string getThumbBmpPath() const;
std::string getThumbBmpPath(int height) const; std::string getThumbBmpPath(int height) const;
bool generateThumbBmp(int height) const; bool generateThumbBmp(int height) const;

214
scripts/debugging_monitor.py Executable file
View File

@ -0,0 +1,214 @@
import sys
import argparse
import re
import threading
from datetime import datetime
from collections import deque
import time
# Try to import potentially missing packages
try:
import serial
from colorama import init, Fore, Style
import matplotlib.pyplot as plt
import matplotlib.animation as animation
except ImportError as e:
missing_package = e.name
print("\n" + "!" * 50)
print(f" Error: The required package '{missing_package}' is not installed.")
print("!" * 50)
print(f"\nTo fix this, please run the following command in your terminal:\n")
install_cmd = "pip install "
packages = []
if 'serial' in str(e): packages.append("pyserial")
if 'colorama' in str(e): packages.append("colorama")
if 'matplotlib' in str(e): packages.append("matplotlib")
print(f" {install_cmd}{' '.join(packages)}")
print("\nExiting...")
sys.exit(1)
# --- Global Variables for Data Sharing ---
# Store last 50 data points
MAX_POINTS = 50
time_data = deque(maxlen=MAX_POINTS)
free_mem_data = deque(maxlen=MAX_POINTS)
total_mem_data = deque(maxlen=MAX_POINTS)
data_lock = threading.Lock() # Prevent reading while writing
# Initialize colors
init(autoreset=True)
def get_color_for_line(line):
"""
Classify log lines by type and assign appropriate colors.
"""
line_upper = line.upper()
if any(keyword in line_upper for keyword in ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"]):
return Fore.RED
if "[MEM]" in line_upper or "FREE:" in line_upper:
return Fore.CYAN
if any(keyword in line_upper for keyword in ["[GFX]", "[ERS]", "DISPLAY", "RAM WRITE", "RAM COMPLETE", "REFRESH", "POWERING ON", "FRAME BUFFER", "LUT"]):
return Fore.MAGENTA
if any(keyword in line_upper for keyword in ["[EBP]", "[BMC]", "[ZIP]", "[PARSER]", "[EHP]", "LOADING EPUB", "CACHE", "DECOMPRESSED", "PARSING"]):
return Fore.GREEN
if "[ACT]" in line_upper or "ENTERING ACTIVITY" in line_upper or "EXITING ACTIVITY" in line_upper:
return Fore.YELLOW
if any(keyword in line_upper for keyword in ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"]):
return Fore.BLUE
if any(keyword in line_upper for keyword in ["[CPS]", "SETTINGS", "[CLEAR_CACHE]"]):
return Fore.LIGHTYELLOW_EX
if any(keyword in line_upper for keyword in ["ESP-ROM", "BUILD:", "RST:", "BOOT:", "SPIWP:", "MODE:", "LOAD:", "ENTRY", "[SD]", "STARTING CROSSPOINT", "VERSION"]):
return Fore.LIGHTBLACK_EX
if "[RBS]" in line_upper:
return Fore.LIGHTCYAN_EX
if "[KRS]" in line_upper:
return Fore.LIGHTMAGENTA_EX
if any(keyword in line_upper for keyword in ["EINKDISPLAY:", "STATIC FRAME", "INITIALIZING", "SPI INITIALIZED", "GPIO PINS", "RESETTING", "SSD1677", "E-INK"]):
return Fore.LIGHTMAGENTA_EX
if any(keyword in line_upper for keyword in ["[FNS]", "FOOTNOTE"]):
return Fore.LIGHTGREEN_EX
if any(keyword in line_upper for keyword in ["[CHAP]", "[OPDS]", "[COF]"]):
return Fore.LIGHTYELLOW_EX
return Fore.WHITE
def parse_memory_line(line):
"""
Extracts Free and Total bytes from the specific log line.
Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes
"""
# Regex to find 'Free: <digits>' and 'Total: <digits>'
match = re.search(r"Free:\s*(\d+).*Total:\s*(\d+)", line)
if match:
try:
free_bytes = int(match.group(1))
total_bytes = int(match.group(2))
return free_bytes, total_bytes
except ValueError:
return None, None
return None, None
def serial_worker(port, baud):
"""
Runs in a background thread. Handles reading serial, printing to console,
and updating the data lists.
"""
print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}")
try:
ser = serial.Serial(port, baud, timeout=0.1)
ser.dtr = False
ser.rts = False
except serial.SerialException as e:
print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}")
return
try:
while True:
try:
raw_data = ser.readline().decode('utf-8', errors='replace')
if not raw_data:
continue
clean_line = raw_data.strip()
if not clean_line:
continue
# Add PC timestamp
pc_time = datetime.now().strftime("%H:%M:%S")
formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line)
# Check for Memory Line
if "[MEM]" in formatted_line:
free_val, total_val = parse_memory_line(formatted_line)
if free_val is not None:
with data_lock:
time_data.append(pc_time)
free_mem_data.append(free_val / 1024) # Convert to KB
total_mem_data.append(total_val / 1024) # Convert to KB
# Print to console
line_color = get_color_for_line(formatted_line)
print(f"{line_color}{formatted_line}")
except OSError:
print(f"{Fore.RED}Device disconnected.{Style.RESET_ALL}")
break
except Exception as e:
# If thread is killed violently (e.g. main exit), silence errors
pass
finally:
if 'ser' in locals() and ser.is_open:
ser.close()
def update_graph(frame):
"""
Called by Matplotlib animation to redraw the chart.
"""
with data_lock:
if not time_data:
return
# Convert deques to lists for plotting
x = list(time_data)
y_free = list(free_mem_data)
y_total = list(total_mem_data)
plt.cla() # Clear axis
# Plot Total RAM
plt.plot(x, y_total, label='Total RAM (KB)', color='red', linestyle='--')
# Plot Free RAM
plt.plot(x, y_free, label='Free RAM (KB)', color='green', marker='o')
# Fill area under Free RAM
plt.fill_between(x, y_free, color='green', alpha=0.1)
plt.title("ESP32 Memory Monitor")
plt.ylabel("Memory (KB)")
plt.xlabel("Time")
plt.legend(loc='upper left')
plt.grid(True, linestyle=':', alpha=0.6)
# Rotate date labels
plt.xticks(rotation=45, ha='right')
plt.tight_layout()
def main():
parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph")
parser.add_argument("port", nargs="?", default="/dev/ttyACM0", help="Serial port")
parser.add_argument("--baud", type=int, default=115200, help="Baud rate")
args = parser.parse_args()
# 1. Start the Serial Reader in a separate thread
# Daemon=True means this thread dies when the main program closes
t = threading.Thread(target=serial_worker, args=(args.port, args.baud), daemon=True)
t.start()
# 2. Set up the Graph (Main Thread)
try:
plt.style.use('light_background')
except:
pass
fig = plt.figure(figsize=(10, 6))
# Update graph every 1000ms
ani = animation.FuncAnimation(fig, update_graph, interval=1000)
try:
print(f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}")
plt.show()
except KeyboardInterrupt:
print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}")
plt.close('all') # Force close any lingering plot windows
if __name__ == "__main__":
main()

View File

@ -14,7 +14,8 @@ constexpr int MAX_RECENT_BOOKS = 10;
RecentBooksStore RecentBooksStore::instance; RecentBooksStore RecentBooksStore::instance;
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author) { void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath) {
// Remove existing entry if present // Remove existing entry if present
auto it = auto it =
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
@ -23,7 +24,7 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title
} }
// Add to front // Add to front
recentBooks.insert(recentBooks.begin(), {path, title, author}); recentBooks.insert(recentBooks.begin(), {path, title, author, coverBmpPath});
// Trim to max size // Trim to max size
if (recentBooks.size() > MAX_RECENT_BOOKS) { if (recentBooks.size() > MAX_RECENT_BOOKS) {
@ -50,6 +51,7 @@ bool RecentBooksStore::saveToFile() const {
serialization::writeString(outputFile, book.path); serialization::writeString(outputFile, book.path);
serialization::writeString(outputFile, book.title); serialization::writeString(outputFile, book.title);
serialization::writeString(outputFile, book.author); serialization::writeString(outputFile, book.author);
serialization::writeString(outputFile, book.coverBmpPath);
} }
outputFile.close(); outputFile.close();
@ -77,7 +79,7 @@ bool RecentBooksStore::loadFromFile() {
serialization::readString(inputFile, path); serialization::readString(inputFile, path);
// Title and author will be empty, they will be filled when the book is // Title and author will be empty, they will be filled when the book is
// opened again // opened again
recentBooks.push_back({path, "", ""}); recentBooks.push_back({path, "", "", ""});
} }
} else { } else {
Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version); Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version);
@ -92,11 +94,12 @@ bool RecentBooksStore::loadFromFile() {
recentBooks.reserve(count); recentBooks.reserve(count);
for (uint8_t i = 0; i < count; i++) { for (uint8_t i = 0; i < count; i++) {
std::string path, title, author; std::string path, title, author, coverBmpPath;
serialization::readString(inputFile, path); serialization::readString(inputFile, path);
serialization::readString(inputFile, title); serialization::readString(inputFile, title);
serialization::readString(inputFile, author); serialization::readString(inputFile, author);
recentBooks.push_back({path, title, author}); serialization::readString(inputFile, coverBmpPath);
recentBooks.push_back({path, title, author, coverBmpPath});
} }
} }

View File

@ -6,15 +6,11 @@ struct RecentBook {
std::string path; std::string path;
std::string title; std::string title;
std::string author; std::string author;
std::string coverBmpPath;
bool operator==(const RecentBook& other) const { return path == other.path; } bool operator==(const RecentBook& other) const { return path == other.path; }
}; };
struct RecentBookWithCover {
RecentBook book;
std::string coverBmpPath;
};
class RecentBooksStore { class RecentBooksStore {
// Static instance // Static instance
static RecentBooksStore instance; static RecentBooksStore instance;
@ -28,7 +24,8 @@ class RecentBooksStore {
static RecentBooksStore& getInstance() { return instance; } static RecentBooksStore& getInstance() { return instance; }
// Add a book to the recent list (moves to front if already exists) // Add a book to the recent list (moves to front if already exists)
void addBook(const std::string& path, const std::string& title, const std::string& author); void addBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath);
// Get the list of recent books (most recent first) // Get the list of recent books (most recent first)
const std::vector<RecentBook>& getBooks() const { return recentBooks; } const std::vector<RecentBook>& getBooks() const { return recentBooks; }

View File

@ -4,6 +4,7 @@
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include <Utf8.h>
#include <Xtc.h> #include <Xtc.h>
#include <cstring> #include <cstring>
@ -34,8 +35,10 @@ int HomeActivity::getMenuItemCount() const {
return count; return count;
} }
void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight, Rect& popupRect) { void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight) {
recentsLoading = true; recentsLoading = true;
bool showingLoading = false;
Rect popupRect;
recentBooks.clear(); recentBooks.clear();
const auto& books = RECENT_BOOKS.getBooks(); const auto& books = RECENT_BOOKS.getBooks();
@ -43,57 +46,57 @@ void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight, Rect& popupRec
int progress = 0; int progress = 0;
for (const RecentBook& book : books) { for (const RecentBook& book : books) {
const std::string& path = book.path;
// Limit to maximum number of recent books // Limit to maximum number of recent books
if (recentBooks.size() >= maxBooks) { if (recentBooks.size() >= maxBooks) {
break; break;
} }
// Skip if file no longer exists // Skip if file no longer exists
if (!SdMan.exists(path.c_str())) { if (!SdMan.exists(book.path.c_str())) {
continue; continue;
} }
std::string coverBmpPath = ""; if (!book.coverBmpPath.empty()) {
std::string lastBookFileName = ""; std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
const size_t lastSlash = path.find_last_of('/'); if (!SdMan.exists(coverPath.c_str())) {
if (lastSlash != std::string::npos) { std::string lastBookFileName = "";
lastBookFileName = path.substr(lastSlash + 1); const size_t lastSlash = book.path.find_last_of('/');
} if (lastSlash != std::string::npos) {
lastBookFileName = book.path.substr(lastSlash + 1);
Serial.printf("Loading recent book: %s\n", path.c_str());
// If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {
Epub epub(path, "/.crosspoint");
epub.load(false);
// Try to generate thumbnail image for Continue Reading card
coverBmpPath = epub.getThumbBmpPath(coverHeight);
if (!SdMan.exists(coverBmpPath.c_str())) {
UITheme::fillPopupProgress(renderer, popupRect, progress * 30);
if (!epub.generateThumbBmp(coverHeight)) {
coverBmpPath = "";
} }
}
} else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") || Serial.printf("Loading recent book: %s\n", book.path.c_str());
StringUtils::checkFileExtension(lastBookFileName, ".xtc")) {
// Handle XTC file // If epub, try to load the metadata for title/author and cover
Xtc xtc(path, "/.crosspoint"); if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {
if (xtc.load()) { Epub epub(book.path, "/.crosspoint");
// Try to generate thumbnail image for Continue Reading card epub.load(false);
coverBmpPath = xtc.getThumbBmpPath(coverHeight);
if (!SdMan.exists(coverBmpPath.c_str())) { // Try to generate thumbnail image for Continue Reading card
if (!showingLoading) {
showingLoading = true;
popupRect = UITheme::drawPopup(renderer, "Loading...");
}
UITheme::fillPopupProgress(renderer, popupRect, progress * 30); UITheme::fillPopupProgress(renderer, popupRect, progress * 30);
if (!xtc.generateThumbBmp(coverHeight)) { epub.generateThumbBmp(coverHeight);
coverBmpPath = ""; } else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") ||
StringUtils::checkFileExtension(lastBookFileName, ".xtc")) {
// Handle XTC file
Xtc xtc(book.path, "/.crosspoint");
if (xtc.load()) {
// Try to generate thumbnail image for Continue Reading card
if (!showingLoading) {
showingLoading = true;
popupRect = UITheme::drawPopup(renderer, "Loading...");
}
UITheme::fillPopupProgress(renderer, popupRect, progress * 30);
xtc.generateThumbBmp(coverHeight);
} }
} }
} }
} }
recentBooks.push_back(RecentBookWithCover{book, coverBmpPath}); recentBooks.push_back(book);
progress++; progress++;
} }
@ -204,7 +207,7 @@ void HomeActivity::loop() {
const int settingsIdx = idx; const int settingsIdx = idx;
if (selectorIndex < recentBooks.size()) { if (selectorIndex < recentBooks.size()) {
onSelectBook(recentBooks[selectorIndex].book.path); onSelectBook(recentBooks[selectorIndex].path);
} else if (menuSelectedIndex == myLibraryIdx) { } else if (menuSelectedIndex == myLibraryIdx) {
onMyLibraryOpen(); onMyLibraryOpen();
} else if (menuSelectedIndex == recentsIdx) { } else if (menuSelectedIndex == recentsIdx) {
@ -257,8 +260,7 @@ void HomeActivity::render() {
std::bind(&HomeActivity::storeCoverBuffer, this)); std::bind(&HomeActivity::storeCoverBuffer, this));
} else if (!recentsLoading && firstRenderDone) { } else if (!recentsLoading && firstRenderDone) {
recentsLoading = true; recentsLoading = true;
Rect popupRect = UITheme::drawPopup(renderer, "Loading..."); loadRecentBooks(metrics.homeRecentBooksCount, metrics.homeCoverHeight);
loadRecentBooks(metrics.homeRecentBooksCount, metrics.homeCoverHeight, popupRect);
} }
} }

View File

@ -9,7 +9,7 @@
#include "../Activity.h" #include "../Activity.h"
#include "./MyLibraryActivity.h" #include "./MyLibraryActivity.h"
struct RecentBookWithCover; struct RecentBook;
struct Rect; struct Rect;
class HomeActivity final : public Activity { class HomeActivity final : public Activity {
@ -26,7 +26,7 @@ class HomeActivity final : public Activity {
bool coverRendered = false; // Track if cover has been rendered once bool coverRendered = false; // Track if cover has been rendered once
bool coverBufferStored = false; // Track if cover buffer is stored bool coverBufferStored = false; // Track if cover buffer is stored
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
std::vector<RecentBookWithCover> recentBooks; std::vector<RecentBook> recentBooks;
const std::function<void(const std::string& path)> onSelectBook; const std::function<void(const std::string& path)> onSelectBook;
const std::function<void()> onMyLibraryOpen; const std::function<void()> onMyLibraryOpen;
const std::function<void()> onRecentsOpen; const std::function<void()> onRecentsOpen;
@ -41,7 +41,7 @@ class HomeActivity final : public Activity {
bool storeCoverBuffer(); // Store frame buffer for cover image bool storeCoverBuffer(); // Store frame buffer for cover image
bool restoreCoverBuffer(); // Restore frame buffer from stored cover bool restoreCoverBuffer(); // Restore frame buffer from stored cover
void freeCoverBuffer(); // Free the stored cover buffer void freeCoverBuffer(); // Free the stored cover buffer
void loadRecentBooks(int maxBooks, int coverHeight, Rect& popupRect); void loadRecentBooks(int maxBooks, int coverHeight);
public: public:
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,

View File

@ -114,7 +114,7 @@ void MyLibraryActivity::loop() {
mappedInput.wasReleased(MappedInputManager::Button::Down); mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, true, true); const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, true);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (files.empty()) { if (files.empty()) {
@ -201,7 +201,7 @@ void MyLibraryActivity::render() const {
} else { } else {
UITheme::drawList( UITheme::drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex, renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
[this](int index) { return files[index]; }, false, nullptr, false, nullptr); [this](int index) { return files[index]; }, false, nullptr, false, nullptr, false, nullptr);
} }
// Help text // Help text

View File

@ -75,7 +75,7 @@ void RecentBooksActivity::loop() {
mappedInput.wasReleased(MappedInputManager::Button::Down); mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, true, true); const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, true);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) { if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
@ -137,7 +137,8 @@ void RecentBooksActivity::render() const {
} else { } else {
UITheme::drawList( UITheme::drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, recentBooks.size(), selectorIndex, renderer, Rect{0, contentTop, pageWidth, contentHeight}, recentBooks.size(), selectorIndex,
[this](int index) { return recentBooks[index].title; }, false, nullptr, false, nullptr); [this](int index) { return recentBooks[index].title; }, true,
[this](int index) { return recentBooks[index].author; }, false, nullptr, false, nullptr);
} }
// Help text // Help text

View File

@ -85,7 +85,7 @@ void EpubReaderActivity::onEnter() {
// Save current epub as last opened epub and add to recent books // Save current epub as last opened epub and add to recent books
APP_STATE.openEpubPath = epub->getPath(); APP_STATE.openEpubPath = epub->getPath();
APP_STATE.saveToFile(); APP_STATE.saveToFile();
RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor()); RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath());
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
@ -575,8 +575,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight; availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight;
titleMarginLeftAdjusted = titleMarginLeft; titleMarginLeftAdjusted = titleMarginLeft;
} }
while (titleWidth > availableTitleSpace && title.length() > 11) { if (titleWidth > availableTitleSpace) {
title.replace(title.length() - 8, 8, "..."); title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace);
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
} }
} }

View File

@ -60,7 +60,7 @@ void TxtReaderActivity::onEnter() {
// Save current txt as last opened file and add to recent books // Save current txt as last opened file and add to recent books
APP_STATE.openEpubPath = txt->getPath(); APP_STATE.openEpubPath = txt->getPath();
APP_STATE.saveToFile(); APP_STATE.saveToFile();
RECENT_BOOKS.addBook(txt->getPath(), "", ""); RECENT_BOOKS.addBook(txt->getPath(), "", "", "");
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
@ -537,8 +537,8 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
std::string title = txt->getTitle(); std::string title = txt->getTitle();
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
while (titleWidth > availableTextWidth && title.length() > 11) { if (titleWidth > availableTextWidth) {
title.replace(title.length() - 8, 8, "..."); title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTextWidth);
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
} }

View File

@ -16,6 +16,7 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "XtcReaderChapterSelectionActivity.h" #include "XtcReaderChapterSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -45,7 +46,7 @@ void XtcReaderActivity::onEnter() {
// Save current XTC as last opened book and add to recent books // Save current XTC as last opened book and add to recent books
APP_STATE.openEpubPath = xtc->getPath(); APP_STATE.openEpubPath = xtc->getPath();
APP_STATE.saveToFile(); APP_STATE.saveToFile();
RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor()); RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor(), xtc->getThumbBmpPath());
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;

View File

@ -277,7 +277,7 @@ void SettingsActivity::render() const {
pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight + pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight +
metrics.verticalSpacing * 2)}, metrics.verticalSpacing * 2)},
settingsCount, selectedSettingIndex - 1, [this](int index) { return std::string(settingsList[index].name); }, settingsCount, selectedSettingIndex - 1, [this](int index) { return std::string(settingsList[index].name); },
false, nullptr, true, false, nullptr, false, nullptr, true,
[this](int i) { [this](int i) {
const auto& setting = settingsList[i]; const auto& setting = settingsList[i];
std::string valueText = ""; std::string valueText = "";

View File

@ -32,7 +32,8 @@ void UITheme::setTheme(CrossPointSettings::UI_THEME type) {
} }
} }
int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints) { int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints,
bool hasSubtitle) {
const ThemeMetrics& metrics = UITheme::getMetrics(); const ThemeMetrics& metrics = UITheme::getMetrics();
int reservedHeight = metrics.topPadding; int reservedHeight = metrics.topPadding;
if (hasHeader) { if (hasHeader) {
@ -45,7 +46,16 @@ int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader
reservedHeight += metrics.verticalSpacing + metrics.buttonHintsHeight; reservedHeight += metrics.verticalSpacing + metrics.buttonHintsHeight;
} }
const int availableHeight = renderer.getScreenHeight() - reservedHeight; const int availableHeight = renderer.getScreenHeight() - reservedHeight;
return availableHeight / metrics.listRowHeight; int rowHeight = hasSubtitle ? metrics.listWithSubtitleRowHeight : metrics.listRowHeight;
return availableHeight / rowHeight;
}
std::string UITheme::getCoverThumbPath(std::string coverBmpPath, int coverHeight) {
size_t pos = coverBmpPath.find("[HEIGHT]", 0);
if (pos != std::string::npos) {
coverBmpPath.replace(pos, 8, std::to_string(coverHeight));
}
return coverBmpPath;
} }
// Forward all component methods to the current theme // Forward all component methods to the current theme
@ -75,11 +85,13 @@ void UITheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBt
} }
void UITheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, void UITheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon, const std::function<std::string(int index)>& rowTitle, bool hasSubtitle,
const std::function<std::string(int index)>& rowSubtitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue, const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) { const std::function<std::string(int index)>& rowValue) {
if (currentTheme != nullptr) { if (currentTheme != nullptr) {
currentTheme->drawList(renderer, rect, itemCount, selectedIndex, rowTitle, hasIcon, rowIcon, hasValue, rowValue); currentTheme->drawList(renderer, rect, itemCount, selectedIndex, rowTitle, hasSubtitle, rowSubtitle, hasIcon,
rowIcon, hasValue, rowValue);
} }
} }
@ -96,7 +108,7 @@ void UITheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std
} }
} }
void UITheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBookWithCover>& recentBooks, void UITheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) { bool& bufferRestored, std::function<bool()> storeCoverBuffer) {
if (currentTheme != nullptr) { if (currentTheme != nullptr) {

View File

@ -6,7 +6,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
class GfxRenderer; class GfxRenderer;
struct RecentBookWithCover; struct RecentBook;
struct Rect { struct Rect {
int x; int x;
@ -34,6 +34,7 @@ struct ThemeMetrics {
int contentSidePadding; int contentSidePadding;
int listRowHeight; int listRowHeight;
int listWithSubtitleRowHeight;
int menuRowHeight; int menuRowHeight;
int menuSpacing; int menuSpacing;
@ -70,20 +71,22 @@ class UITheme {
static void initialize(); static void initialize();
static void setTheme(CrossPointSettings::UI_THEME type); static void setTheme(CrossPointSettings::UI_THEME type);
static const ThemeMetrics& getMetrics() { return *currentMetrics; } static const ThemeMetrics& getMetrics() { return *currentMetrics; }
static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints); static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints,
bool hasSubtitle);
static std::string getCoverThumbPath(std::string coverBmpPath, int coverHeight);
static void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total); static void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total);
static void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true); static void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true);
static void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, static void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4); const char* btn4);
static void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn); static void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn);
static void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, static void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon, const std::function<std::string(int index)>& rowTitle, bool hasSubtitle,
const std::function<std::string(int index)>& rowSubtitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue, const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue); const std::function<std::string(int index)>& rowValue);
static void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title); static void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title);
static void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, bool selected); static void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, bool selected);
static void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBookWithCover>& recentBooks, static void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer); bool& bufferRestored, std::function<bool()> storeCoverBuffer);
static void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, static void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,

View File

@ -2,6 +2,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include <Utf8.h>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
@ -9,7 +10,6 @@
#include "Battery.h" #include "Battery.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/StringUtils.h"
// Internal constants // Internal constants
namespace { namespace {
@ -156,11 +156,12 @@ void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top
} }
void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon, const std::function<std::string(int index)>& rowTitle, bool hasSubtitle,
const std::function<std::string(int index)>& rowSubtitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue, const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) { const std::function<std::string(int index)>& rowValue) {
int pageItems = rect.height / BaseMetrics::values.listRowHeight; int rowHeight = hasSubtitle ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
const int rowHeight = BaseMetrics::values.listRowHeight; int pageItems = rect.height / rowHeight;
const int totalPages = (itemCount + pageItems - 1) / pageItems; const int totalPages = (itemCount + pageItems - 1) / pageItems;
if (totalPages > 1) { if (totalPages > 1) {
@ -189,29 +190,36 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
} }
// Draw selection // Draw selection
int contentWidth = rect.width - BaseMetrics::values.sideButtonHintsWidth - 5; int contentWidth = rect.width - 5;
if (selectedIndex >= 0) { if (selectedIndex >= 0) {
renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, contentWidth, rowHeight); renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight);
} }
// Draw all items // Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems; const auto pageStartIndex = selectedIndex / pageItems * pageItems;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
const int itemY = rect.y + (i % pageItems) * rowHeight; const int itemY = rect.y + (i % pageItems) * rowHeight;
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (hasValue ? 60 : 0);
// Draw name // Draw name
auto itemName = rowTitle(i); auto itemName = rowTitle(i);
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), auto font = hasSubtitle ? UI_12_FONT_ID : UI_10_FONT_ID;
contentWidth - BaseMetrics::values.contentSidePadding * 2 - auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
(hasValue ? 60 : 0)); // TODO truncate according to value width? renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(),
i != selectedIndex); if (hasSubtitle) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(),
i != selectedIndex);
}
if (hasValue) { if (hasValue) {
// Draw value // Draw value
std::string valueText = rowValue(i); std::string valueText = rowValue(i);
const auto textWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
renderer.drawText(UI_10_FONT_ID, contentWidth - BaseMetrics::values.contentSidePadding - textWidth, itemY, renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
valueText.c_str(), i != selectedIndex); itemY, valueText.c_str(), i != selectedIndex);
} }
} }
} }
@ -229,7 +237,11 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
showBatteryPercentage); showBatteryPercentage);
if (title) { if (title) {
renderer.drawCenteredText(UI_12_FONT_ID, rect.y + 5, title, true, EpdFontFamily::BOLD); int padding = rect.width - batteryX;
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title,
rect.width - padding * 2 - BaseMetrics::values.contentSidePadding * 2,
EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, rect.y + 5, truncatedTitle.c_str(), true, EpdFontFamily::BOLD);
} }
} }
@ -265,10 +277,9 @@ void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const s
// Draw the "Recent Book" cover card on the home screen // Draw the "Recent Book" cover card on the home screen
// TODO: Refactor method to make it cleaner, split into smaller methods // TODO: Refactor method to make it cleaner, split into smaller methods
void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const std::vector<RecentBookWithCover>& recentBooks, const int selectorIndex, const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, bool& bufferRestored, std::function<bool()> storeCoverBuffer) {
std::function<bool()> storeCoverBuffer) {
// --- Top "book" card for the current title (selectorIndex == 0) --- // --- Top "book" card for the current title (selectorIndex == 0) ---
const int bookWidth = rect.width / 2; const int bookWidth = rect.width / 2;
const int bookHeight = rect.height; const int bookHeight = rect.height;
@ -288,7 +299,8 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect,
// Draw cover image as background if available (inside the box) // Draw cover image as background if available (inside the box)
// Only load from SD on first render, then use stored buffer // Only load from SD on first render, then use stored buffer
if (hasContinueReading && !recentBooks[0].coverBmpPath.empty() && !coverRendered) { if (hasContinueReading && !recentBooks[0].coverBmpPath.empty() && !coverRendered) {
const std::string& coverBmpPath = recentBooks[0].coverBmpPath; const std::string coverBmpPath =
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight);
// First time: load cover from SD and render // First time: load cover from SD and render
FsFile file; FsFile file;
@ -378,8 +390,8 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect,
} }
if (hasContinueReading) { if (hasContinueReading) {
const std::string& lastBookTitle = recentBooks[0].book.title; const std::string& lastBookTitle = recentBooks[0].title;
const std::string& lastBookAuthor = recentBooks[0].book.author; const std::string& lastBookAuthor = recentBooks[0].author;
// Invert text colors based on selection state: // Invert text colors based on selection state:
// - With cover: selected = white text on black box, unselected = black text on white box // - With cover: selected = white text on black box, unselected = black text on white box
@ -419,7 +431,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect,
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
// Remove "..." first, then remove one UTF-8 char, then add "..." back // Remove "..." first, then remove one UTF-8 char, then add "..." back
lines.back().resize(lines.back().size() - 3); // Remove "..." lines.back().resize(lines.back().size() - 3); // Remove "..."
StringUtils::utf8RemoveLastChar(lines.back()); utf8RemoveLastChar(lines.back());
lines.back().append("..."); lines.back().append("...");
} }
break; break;
@ -428,7 +440,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect,
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
while (wordWidth > maxLineWidth && !i.empty()) { while (wordWidth > maxLineWidth && !i.empty()) {
// Word itself is too long, trim it (UTF-8 safe) // Word itself is too long, trim it (UTF-8 safe)
StringUtils::utf8RemoveLastChar(i); utf8RemoveLastChar(i);
// Check if we have room for ellipsis // Check if we have room for ellipsis
std::string withEllipsis = i + "..."; std::string withEllipsis = i + "...";
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
@ -481,7 +493,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect,
if (!lastBookAuthor.empty()) { if (!lastBookAuthor.empty()) {
std::string trimmedAuthor = lastBookAuthor; std::string trimmedAuthor = lastBookAuthor;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor); utf8RemoveLastChar(trimmedAuthor);
} }
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
@ -515,14 +527,14 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect,
// Trim author if too long (UTF-8 safe) // Trim author if too long (UTF-8 safe)
bool wasTrimmed = false; bool wasTrimmed = false;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor); utf8RemoveLastChar(trimmedAuthor);
wasTrimmed = true; wasTrimmed = true;
} }
if (wasTrimmed && !trimmedAuthor.empty()) { if (wasTrimmed && !trimmedAuthor.empty()) {
// Make room for ellipsis // Make room for ellipsis
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
!trimmedAuthor.empty()) { !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor); utf8RemoveLastChar(trimmedAuthor);
} }
trimmedAuthor.append("..."); trimmedAuthor.append("...");
} }

View File

@ -7,7 +7,6 @@
#include "components/UITheme.h" #include "components/UITheme.h"
class GfxRenderer; class GfxRenderer;
struct RecentBookInfo;
// Default theme implementation (Classic Theme) // Default theme implementation (Classic Theme)
// Additional themes can inherit from this and override methods as needed // Additional themes can inherit from this and override methods as needed
@ -21,6 +20,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.verticalSpacing = 10, .verticalSpacing = 10,
.contentSidePadding = 20, .contentSidePadding = 20,
.listRowHeight = 30, .listRowHeight = 30,
.listWithSubtitleRowHeight = 65,
.menuRowHeight = 45, .menuRowHeight = 45,
.menuSpacing = 8, .menuSpacing = 8,
.tabSpacing = 10, .tabSpacing = 10,
@ -49,16 +49,16 @@ class BaseTheme {
const char* btn4); const char* btn4);
virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn); virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn);
virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon, const std::function<std::string(int index)>& rowTitle, bool hasSubtitle,
const std::function<std::string(int index)>& rowSubtitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue, const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue); const std::function<std::string(int index)>& rowValue);
virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title); virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title);
virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, bool selected); virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, bool selected);
virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const std::vector<RecentBookWithCover>& recentBooks, const int selectorIndex, const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, bool& bufferRestored, std::function<bool()> storeCoverBuffer);
std::function<bool()> storeCoverBuffer);
virtual void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, virtual void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon, const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon); const std::function<std::string(int index)>& rowIcon);

View File

@ -72,8 +72,11 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
showBatteryPercentage); showBatteryPercentage);
if (title) { if (title) {
auto truncatedTitle = renderer.truncatedText(
UI_12_FONT_ID, title, rect.width - LyraMetrics::values.contentSidePadding * 2, EpdFontFamily::BOLD);
renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding, renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding,
rect.y + LyraMetrics::values.batteryBarHeight + 3, title, true, EpdFontFamily::BOLD); rect.y + LyraMetrics::values.batteryBarHeight + 3, truncatedTitle.c_str(), true,
EpdFontFamily::BOLD);
renderer.drawLine(rect.x, rect.y + rect.height - 3, rect.x + rect.width, rect.y + rect.height - 3, 3, true); renderer.drawLine(rect.x, rect.y + rect.height - 3, rect.x + rect.width, rect.y + rect.height - 3, 3, true);
} }
} }
@ -82,7 +85,7 @@ void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::ve
int currentX = rect.x + LyraMetrics::values.contentSidePadding; int currentX = rect.x + LyraMetrics::values.contentSidePadding;
if (selected) { if (selected) {
renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, COLOR_LIGHT_GRAY); renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray);
} }
for (const auto& tab : tabs) { for (const auto& tab : tabs) {
@ -91,10 +94,10 @@ void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::ve
if (tab.selected) { if (tab.selected) {
if (selected) { if (selected) {
renderer.fillRoundedRect(currentX, rect.y + 1, textWidth + 2 * hPaddingInSelection, rect.height - 4, renderer.fillRoundedRect(currentX, rect.y + 1, textWidth + 2 * hPaddingInSelection, rect.height - 4,
cornerRadius, COLOR_BLACK); cornerRadius, Color::Black);
} else { } else {
renderer.fillRectDither(currentX, rect.y, textWidth + 2 * hPaddingInSelection, rect.height - 3, renderer.fillRectDither(currentX, rect.y, textWidth + 2 * hPaddingInSelection, rect.height - 3,
COLOR_LIGHT_GRAY); Color::LightGray);
renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + textWidth + 2 * hPaddingInSelection, renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + textWidth + 2 * hPaddingInSelection,
rect.y + rect.height - 3, 2, true); rect.y + rect.height - 3, 2, true);
} }
@ -110,11 +113,12 @@ void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::ve
} }
void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon, const std::function<std::string(int index)>& rowTitle, bool hasSubtitle,
const std::function<std::string(int index)>& rowSubtitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue, const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) { const std::function<std::string(int index)>& rowValue) {
int pageItems = rect.height / LyraMetrics::values.listRowHeight; int rowHeight = hasSubtitle ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
const int rowHeight = LyraMetrics::values.listRowHeight; int pageItems = rect.height / rowHeight;
const int totalPages = (itemCount + pageItems - 1) / pageItems; const int totalPages = (itemCount + pageItems - 1) / pageItems;
if (totalPages > 1) { if (totalPages > 1) {
@ -137,7 +141,7 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
if (selectedIndex >= 0) { if (selectedIndex >= 0) {
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight, renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight,
contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius, contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius,
COLOR_LIGHT_GRAY); Color::LightGray);
} }
// Draw all items // Draw all items
@ -146,28 +150,35 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const int itemY = rect.y + (i % pageItems) * rowHeight; const int itemY = rect.y + (i % pageItems) * rowHeight;
// Draw name // Draw name
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 -
(hasValue ? 60 : 0); // TODO truncate according to value width?
auto itemName = rowTitle(i); auto itemName = rowTitle(i);
auto item = auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), textWidth);
renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(),
contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 -
(hasValue ? 60 : 0)); // TODO truncate according to value width?
renderer.drawText(UI_10_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2, renderer.drawText(UI_10_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2,
itemY + 6, item.c_str(), true); itemY + 6, item.c_str(), true);
if (hasSubtitle) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), textWidth);
renderer.drawText(SMALL_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2,
itemY + 30, subtitle.c_str(), true);
}
if (hasValue) { if (hasValue) {
// Draw value // Draw value
std::string valueText = rowValue(i); std::string valueText = rowValue(i);
if (!valueText.empty()) { if (!valueText.empty()) {
const auto textWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
if (i == selectedIndex) { if (i == selectedIndex) {
renderer.fillRoundedRect( renderer.fillRoundedRect(
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection * 2 - textWidth, itemY, contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection * 2 - valueTextWidth, itemY,
textWidth + hPaddingInSelection * 2, rowHeight, cornerRadius, COLOR_BLACK); valueTextWidth + hPaddingInSelection * 2, rowHeight, cornerRadius, Color::Black);
} }
renderer.drawText(UI_10_FONT_ID, renderer.drawText(UI_10_FONT_ID,
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - textWidth, contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueTextWidth,
itemY + 6, valueText.c_str(), i != selectedIndex); itemY + 6, valueText.c_str(), i != selectedIndex);
} }
} }
@ -243,10 +254,9 @@ void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top
} }
} }
void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const std::vector<RecentBookWithCover>& recentBooks, const int selectorIndex, const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, bool& bufferRestored, std::function<bool()> storeCoverBuffer) {
std::function<bool()> storeCoverBuffer) {
const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / 3; const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / 3;
const int tileHeight = rect.height; const int tileHeight = rect.height;
const int bookTitleHeight = tileHeight - LyraMetrics::values.homeCoverHeight - hPaddingInSelection; const int bookTitleHeight = tileHeight - LyraMetrics::values.homeCoverHeight - hPaddingInSelection;
@ -260,12 +270,13 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect,
if (!coverRendered) { if (!coverRendered) {
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
i++) { i++) {
const std::string& coverBmpPath = recentBooks[i].coverBmpPath; std::string coverPath = recentBooks[i].coverBmpPath;
if (coverPath.empty()) {
if (coverBmpPath.empty()) {
continue; continue;
} }
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight);
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i; int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
// First time: load cover from SD and render // First time: load cover from SD and render
@ -296,18 +307,18 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect,
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i; int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
auto title = auto title =
renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].book.title.c_str(), tileWidth - 2 * hPaddingInSelection); renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection);
if (bookSelected) { if (bookSelected) {
// Draw selection box // Draw selection box
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false, renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
COLOR_LIGHT_GRAY); Color::LightGray);
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
LyraMetrics::values.homeCoverHeight, COLOR_LIGHT_GRAY); LyraMetrics::values.homeCoverHeight, Color::LightGray);
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection, renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
hPaddingInSelection, LyraMetrics::values.homeCoverHeight, COLOR_LIGHT_GRAY); hPaddingInSelection, LyraMetrics::values.homeCoverHeight, Color::LightGray);
renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth, renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth,
bookTitleHeight, cornerRadius, false, false, true, true, COLOR_LIGHT_GRAY); bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray);
} }
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection, renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection,
tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true); tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true);
@ -328,7 +339,7 @@ void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount
const bool selected = selectedIndex == i; const bool selected = selectedIndex == i;
if (selected) { if (selected) {
renderer.fillRoundedRect(tileRect.x, tileRect.y, tileRect.width, tileRect.height, cornerRadius, COLOR_LIGHT_GRAY); renderer.fillRoundedRect(tileRect.x, tileRect.y, tileRect.width, tileRect.height, cornerRadius, Color::LightGray);
} }
const char* label = buttonLabel(i).c_str(); const char* label = buttonLabel(i).c_str();

View File

@ -14,6 +14,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
.verticalSpacing = 16, .verticalSpacing = 16,
.contentSidePadding = 20, .contentSidePadding = 20,
.listRowHeight = 40, .listRowHeight = 40,
.listWithSubtitleRowHeight = 60,
.menuRowHeight = 64, .menuRowHeight = 64,
.menuSpacing = 8, .menuSpacing = 8,
.tabSpacing = 8, .tabSpacing = 8,
@ -39,7 +40,8 @@ class LyraTheme : public BaseTheme {
void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) override; void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) override;
void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, bool selected) override; void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, bool selected) override;
void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon, const std::function<std::string(int index)>& rowTitle, bool hasSubtitle,
const std::function<std::string(int index)>& rowSubtitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue, const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) override; const std::function<std::string(int index)>& rowValue) override;
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
@ -48,7 +50,7 @@ class LyraTheme : public BaseTheme {
void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon, const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon) override; const std::function<std::string(int index)>& rowIcon) override;
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBookWithCover>& recentBooks, void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer) override; std::function<bool()> storeCoverBuffer) override;
virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) override; virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) override;

View File

@ -61,23 +61,4 @@ bool checkFileExtension(const String& fileName, const char* extension) {
return localFile.endsWith(localExtension); return localFile.endsWith(localExtension);
} }
size_t utf8RemoveLastChar(std::string& str) {
if (str.empty()) return 0;
size_t pos = str.size() - 1;
// Walk back to find the start of the last UTF-8 character
// UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF)
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
--pos;
}
str.resize(pos);
return pos;
}
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, const size_t numChars) {
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
utf8RemoveLastChar(str);
}
}
} // namespace StringUtils } // namespace StringUtils

View File

@ -19,10 +19,4 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
bool checkFileExtension(const std::string& fileName, const char* extension); bool checkFileExtension(const std::string& fileName, const char* extension);
bool checkFileExtension(const String& fileName, const char* extension); bool checkFileExtension(const String& fileName, const char* extension);
// UTF-8 safe string truncation - removes one character from the end
// Returns the new size after removing one UTF-8 character
size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 characters from the end
void utf8TruncateChars(std::string& str, size_t numChars);
} // namespace StringUtils } // namespace StringUtils