Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8940d64
perf: draw text with less per-pixel work
uxjulia Sep 27, 2026
057a585
perf: reuse the Library index between file changes
uxjulia Sep 27, 2026
f593c8d
perf: load Home book details from saved caches
uxjulia Sep 27, 2026
a3a1511
perf: keep reader background work from waiting on rendering
uxjulia Sep 27, 2026
2480721
perf: share SD font lookup tables across styles
uxjulia Sep 27, 2026
9a19a79
feat: customize top and bottom reader status bars
uxjulia Sep 27, 2026
a30c27d
feat: simplify Library controls on button devices
uxjulia Sep 27, 2026
04a341f
feat: unify EPUB reader menus across devices
uxjulia Sep 27, 2026
168b88b
fix: align clock and battery across reader screens
uxjulia Sep 27, 2026
a5423d1
fix: make light gestures adjust one point at a time
uxjulia Sep 27, 2026
0b1dfc2
fix: show Library scan message after restarting
uxjulia Sep 27, 2026
8962343
feat: use full-screen reader menus on X3 and X4
uxjulia Sep 27, 2026
3558a68
feat: allow three status bar items on each side
uxjulia Sep 27, 2026
ace6565
feat: add Library to shortcut options
uxjulia Sep 27, 2026
c299081
fix: let touch users scroll status bar options
uxjulia Sep 27, 2026
8b73073
fix: show file count in Library footer and update date format
uxjulia Sep 27, 2026
a20b992
fix: keep reading data during Library scans
uxjulia Sep 28, 2026
712f4fd
fix: keep File Transfer choices unselected on touch
uxjulia Sep 28, 2026
ba32a56
fix: restore library button hints that were accidentally removed
uxjulia Sep 28, 2026
9bce7a9
fix: labels on Go to % screen say +/- 1 instead of left/right
uxjulia Sep 28, 2026
8182eb9
fix: keep reader font previews clear while switching fonts
uxjulia Sep 28, 2026
9d009e7
fix: show new in-reader menu on X4 Classic
uxjulia Sep 28, 2026
8f20144
fix: refresh keyboard layout and spacing
uxjulia Sep 28, 2026
a6b2e8e
feat: show chapter/book progress in the x4 pro frontlight drawer when…
uxjulia Sep 28, 2026
bc1ecb3
Merge uxjulia/crossink development (a6b2e8e)
claude Sep 28, 2026
e22f3bb
refactor: clear cppcheck 2.20 findings in merged upstream code
claude Sep 28, 2026
0b1b2ec
refactor: count visible status bar slots with std::count_if
claude Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
### Added

- The web status API (`/api/status`) reports a `temperatures` block: the chip temperature on ESP32-S3 devices, the battery fuel gauge's temperature on boards that have one (such as the X3), and on the X4 Pro the e-ink panel controller's own sensor, sampled after a screen refresh at most once a minute. Sensors a board lacks are left out.
- Assign Library to power, long-press, button-chord, Home-button, or Quick Actions shortcuts to open the book list directly.
- Customize the top and bottom reader status bars separately, including item positions and progress bars, in EPUB, TXT, and XTC books. Each bar can be previewed where it appears while reading.
- View a selected book's reading stats from its Library or File Browser action menu.
- Library replaces Recent Books with a searchable book list, and adds various book metadata sort options.
- Reset a book's reader settings from the in-reader Settings tab.
Expand All @@ -17,6 +19,8 @@
- On devices with PSRAM (Sticky, X4 Pro), the OPDS browser downloads the next page of a catalog in the background while you browse, and keeps pages you have visited in memory, so Next page, Previous page and Back open without waiting on the server.
- The web file manager's image preview has previous and next buttons, and the left and right arrow keys, to step through the images in the current folder. Other file types are skipped, and stepping wraps around at either end. The preview also shows the image's position in the folder, its pixel dimensions, its file size and, when the file list has one, its modified date.
- The Settings > System footer shows the firmware's branch (the batch number, such as `b11`, for combined test builds) and commit, with `*` when built from uncommitted changes, under the version. The System list stops above the footer instead of running under it. The web status API reports the full branch, build number (the commit count unless the build sets `CROSSINK_BUILD_NUMBER`; left out for shallow checkouts) and UTC build time.
- In-reader menu for X3/X4/X4 Classic have been updated to a modified version of the in-reader menu for touch devices
- Chapter pages and book progress information is displayed in the frontlight drawer when in the reader for X4 Pro

### Changed

Expand All @@ -36,9 +40,16 @@
- Far fewer SD card writes when saving your place: EPUB progress now alternates between two small slot files that are overwritten in place, so a save costs about 2 sector writes instead of roughly a dozen, and a save that would store the position already on the card (such as closing a book without turning a page) writes nothing. Your place is now saved every 30 page turns or 15 minutes of reading instead of every 10 pages or 5 minutes; leaving the book or putting the device to sleep still saves it immediately, so only a crash, reset or dead battery can lose more pages than before. A save interrupted by power loss falls back to the previous save. TXT and XTC progress and the Home reading percentage are also overwritten in place instead of being truncated and rewritten.
- Firmware is about 32 KB smaller: wolfSSL no longer builds its debug trace messages in. Builds with `-DFREEINK_WOLFSSL_DEBUG` still include them.
- The X4 Pro light-sleep firmware now uses the scalable TTF versions of Bitter and Lexend Deca, like the standard X4 Pro build, making it about 360 KB smaller.
- Text drawing resolves clipping and screen rotation once per glyph, reducing work when painting menus and book pages.
- Home reads saved EPUB progress and chapter metadata without opening or indexing the book, and stops saved-item checks after the first file.
- Optional EPUB background work yields immediately when rendering is busy, keeping input polling responsive.
- SD-card fonts share identical character lookup tables across styles, reducing memory use and repeated card reads.
- EPUB reader menus now share five tabs across devices. Button devices gain live font and margin previews, Reading Stats, and in-book transfer options.
- The on-screen keyboard now uses wider outlined keys with clearer spacing on touch and button devices.

### Fixed

- File Transfer choices no longer appear preselected when opened on a touch device.
- On the X4 Pro and Sticky, the Home screen no longer stalls waiting for the SD card while the Library indexes in the background.
- Small images that are scaled up, such as a short progressive JPEG, are now cached after the first draw instead of being decoded again on every screen refresh.
- The serial log no longer reports errors for normal events: a missing image, section or dictionary cache file on first open, an outdated section cache being rebuilt, and a device without a clock chip or motion sensor. Log lines that were missing a line break now end cleanly, and SD, display, TLS and frontlight messages use the standard log format.
Expand Down Expand Up @@ -131,7 +142,7 @@
- EPUB dictionary lookup can select an individual part of a hyphenated word.
- Short Power-button frontlight and touchscreen shortcuts in EPUB books no longer run the configured long-press action.
- Silent restarts now preserve the frontlight state instead of applying wake or schedule settings.
- The Home button now returns from Customize Status Bar to the previous menu instead of leaving the reader.
- The Home button now returns from Status Bars to the previous menu instead of leaving the reader.
- OPDS book downloads can follow secure redirects without sharing catalog credentials with the download host.
- Larger EPUB stylesheets work on PSRAM readers, including rules that hide duplicate images.
- JPEG-heavy EPUBs can use PSRAM for decoding on supported readers, leaving internal memory available for reading.
Expand Down
10 changes: 8 additions & 2 deletions docs/data-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,14 @@ For binary file layout details, see [File Formats](./file-formats.md).

## Library

Library replaces the Recent Books screen. It reconciles the SD card on entry and
through the refresh icon in the Library header, reusing metadata for unchanged books.
Library replaces the Recent Books screen.

The Library reconciles its index with the SD card on the first visit after boot,
after file changes in File Browser, and after leaving a download or file-transfer
screen. Ordinary return visits reuse the index. Failed or memory-limited scans are
retried on the next visit. The Library's refresh action always scans again; use it
if files were changed externally while the firmware stayed running.
Unchanged books reuse their cached metadata.
**Settings > Display > Use Book Metadata** selects embedded EPUB titles and
authors; disabling it uses filenames. TXT, Markdown and XTC files use filename
fallbacks. CLX1 version 2 adds a first-name author permutation; older Library
Expand Down
2 changes: 1 addition & 1 deletion docs/reader-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ consistent when you change fonts, spacing, orientation, or indexing mode.
To enable them:

1. Open the reader menu and select **Reader Options**.
2. Select **Customize Status Bar**.
2. Select **Status Bars**.
3. Toggle **Stable Page Numbers** on.

The option appears only when the current EPUB contains valid CrossInk reference
Expand Down
5 changes: 3 additions & 2 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,8 @@ Book Cache** does not reset these choices.
while a book is open, while leaving touch available in reader menus so you can
turn it back on.

- **Customize Status Bar**: Configure the status bar displayed while reading:
- **Status Bars**: Configure the status bars displayed while reading:
- Top and bottom bars each have three left slots, one center slot, and three right slots.
- Chapter Page Count - Show/Hide the current page in the chapter (ex: 5/25). Page count may change based on the font size and margins set.
- Book Progress Percentage - Show/Hide the current percent progress in the book.
- Percentage Format - Show book progress as `10%` (default), `10.1%`, or `10.12%`.
Expand All @@ -333,7 +334,7 @@ in **Settings > Controls > Taps & Gestures**), tap the status-bar area while
reading to show or hide the entire status bar for the current reading session.
This tap is available while **Touch Reader Controls** is enabled. The quick
toggle does not change the page layout or page breaks; tap the same status-bar
region again to restore a hidden bar. Use **Customize Status Bar** to choose
region again to restore a hidden bar. Use **Status Bars** to choose
which status-bar items are shown.

#### 3.6.3 Controls
Expand Down
3 changes: 3 additions & 0 deletions include/AppCapabilities.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@
#error "CROSSINK_APP_CAP_USB_DRIVE must match FREEINK_CAP_USB_MSC"
#endif
#endif

// Every button-only reader uses the full-screen menu and its bounded sample preview.
#define CROSSINK_APP_READER_SAMPLE_PREVIEW (!CROSSINK_APP_CAP_TOUCH)
50 changes: 50 additions & 0 deletions include/DateFormatting.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#pragma once

#include <cstddef>
#include <cstdint>
#include <cstdio>

// Format a known date using the persisted date-format values shared by settings and HalClock.
inline bool formatDateParts(char* buf, const size_t bufSize, const uint16_t year, const uint8_t month,
const uint8_t day, const uint8_t dateFormat, const char numericSeparator = '/') {
if (bufSize == 0 || month < 1 || month > 12 || day < 1 || day > 31) return false;

static constexpr const char* monthNames[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
static constexpr const char* fullMonthNames[] = {"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"};
const char separator = numericSeparator == '.' || numericSeparator == '-' ? numericSeparator : '/';
int length = 0;
switch (dateFormat) {
case 1: // Day month year, long
length = std::snprintf(buf, bufSize, "%02u %s %u", day, monthNames[month - 1], year);
break;
case 2: // Month day year, numeric
length = std::snprintf(buf, bufSize, "%02u%c%02u%c%u", month, separator, day, separator, year);
break;
case 3: // Day month year, numeric
length = std::snprintf(buf, bufSize, "%02u%c%02u%c%u", day, separator, month, separator, year);
break;
case 4: // Year month day, numeric
length = std::snprintf(buf, bufSize, "%u%c%02u%c%02u", year, separator, month, separator, day);
break;
case 5: // Month day, numeric
length = std::snprintf(buf, bufSize, "%02u%c%02u", month, separator, day);
break;
case 6: // Day month, numeric
length = std::snprintf(buf, bufSize, "%02u%c%02u", day, separator, month);
break;
case 7: // Month day, long
length = std::snprintf(buf, bufSize, "%s %02u", fullMonthNames[month - 1], day);
break;
case 8: // Day month, long
length = std::snprintf(buf, bufSize, "%02u %s", day, fullMonthNames[month - 1]);
break;
case 0: // Month day year, long
default:
length = std::snprintf(buf, bufSize, "%s %02u, %u", monthNames[month - 1], day, year);
break;
}
return length >= 0 && static_cast<size_t>(length) < bufSize;
}
61 changes: 57 additions & 4 deletions lib/EpdFont/SdCardFont.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,15 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) {

void SdCardFont::freeStyleAll(PerStyle& s) {
freeStyleMiniData(s);
delete[] s.fullIntervals;
// A shared table is owned by the style it was aliased from -- that style's own
// freeStyleAll() call frees it. Deleting it here too would double-free.
if (!s.intervalsShared) {
delete[] s.fullIntervals;
delete[] s.bmpIntervals;
}
s.fullIntervals = nullptr;
delete[] s.bmpIntervals;
s.bmpIntervals = nullptr;
s.intervalsShared = false;
s.intervalsAreBmp16 = false;
freeStyleKernLigatureData(s);
s.present = false;
Expand Down Expand Up @@ -678,6 +683,22 @@ bool SdCardFont::load(const char* path) {
uint32_t expectedOffset = 0;
uint32_t prevLast = 0;
EpdUnicodeInterval iv{};

// Regular/bold/italic weights of the same family almost always cover the identical codepoint
// set, so a later style's table is usually a byte-for-byte copy of an earlier one's. Sharing
// it saves a full table per style, which on a broad CJK font is tens of KB, and saves the
// PEAK rather than just the residency: allocating first and de-duplicating afterwards still
// needs both tables at once, and that peak is what fails on a tight heap.
// The decision rides along with the validation read below -- every record is already being
// read here -- so it costs no second pass over the table and no buffer to hold one.
// A style stays a candidate only while its table has matched every record so far.
uint8_t shareCandidates = 0;
for (uint8_t k = 0; k < i; k++) {
const auto& owner = styles_[k];
if (!owner.present || owner.header.intervalCount != s.header.intervalCount) continue;
if (!owner.bmpIntervals && !owner.fullIntervals) continue;
shareCandidates |= static_cast<uint8_t>(1u << k);
}
for (uint32_t j = 0; j < s.header.intervalCount; ++j) {
if (file.read(reinterpret_cast<uint8_t*>(&iv), sizeof(iv)) != sizeof(iv)) {
LOG_ERR("SDCF", "Failed to read interval %u for style %u", j, i);
Expand Down Expand Up @@ -706,17 +727,49 @@ bool SdCardFont::load(const char* path) {
if (iv.first > UINT16_MAX || iv.last > UINT16_MAX || iv.offset > UINT16_MAX) {
canUseBmp16 = false;
}
for (uint8_t k = 0; k < i && shareCandidates != 0; k++) {
if ((shareCandidates & (1u << k)) == 0) continue;
const auto& owner = styles_[k];
// Compared by value, so an above-BMP record never equals a compact one and drops out here.
const bool same = owner.intervalsAreBmp16
? (owner.bmpIntervals[j].first == iv.first && owner.bmpIntervals[j].last == iv.last &&
owner.bmpIntervals[j].offset == iv.offset)
: (owner.fullIntervals[j].first == iv.first && owner.fullIntervals[j].last == iv.last &&
owner.fullIntervals[j].offset == iv.offset);
if (!same) shareCandidates &= static_cast<uint8_t>(~(1u << k));
}
expectedOffset += span;
prevLast = iv.last;
}

if (!file.seekSet(s.intervalsFileOffset)) {
// Survived every record: alias the earlier style's table instead of allocating a copy.
// freeStyleAll() skips delete[] when intervalsShared is set, so only the owner frees.
for (uint8_t k = 0; k < i && shareCandidates != 0; k++) {
if ((shareCandidates & (1u << k)) == 0) continue;
auto& owner = styles_[k];
// Identical content can still be held in the other resident form when the two styles
// disagree on glyph count; aliasing across forms would misread the table.
if (owner.intervalsAreBmp16 != canUseBmp16) continue;
s.bmpIntervals = owner.bmpIntervals;
s.fullIntervals = owner.fullIntervals;
s.intervalsAreBmp16 = owner.intervalsAreBmp16;
s.intervalsShared = true;
LOG_DBG("SDCF", "Style %u: sharing style %u's %u-interval table (%u B not allocated)", i, k,
s.header.intervalCount,
s.header.intervalCount * (canUseBmp16 ? 6u : static_cast<uint32_t>(sizeof(EpdUnicodeInterval))));
break;
}

// Only the allocate-and-read path below needs the records again; a shared style is done.
if (!s.intervalsShared && !file.seekSet(s.intervalsFileOffset)) {
LOG_ERR("SDCF", "Failed to seek back to intervals for style %u", i);
freeAll();
return false;
}

if (canUseBmp16) {
if (s.intervalsShared) {
// Aliased above; fall through to the stub/metadata setup without touching the table.
} else if (canUseBmp16) {
s.bmpIntervals = new (std::nothrow) PerStyle::BmpInterval16[s.header.intervalCount];
if (!s.bmpIntervals) {
LOG_ERR("SDCF", "Failed to allocate compact intervals for style %u", i);
Expand Down
5 changes: 5 additions & 0 deletions lib/EpdFont/SdCardFont.h
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ class SdCardFont {
static_assert(sizeof(BmpInterval16) == 6, "BmpInterval16 must remain compact");
BmpInterval16* bmpIntervals = nullptr;
bool intervalsAreBmp16 = false;
// True when bmpIntervals/fullIntervals above points at another style's table rather than
// this style's own allocation. Regular/bold/italic weights of the same family almost always
// cover the identical codepoint set, so a CJK font's multi-KB-per-style table is otherwise
// paid for once per style. Only the owning style frees it -- see freeStyleAll().
bool intervalsShared = false;

// Persistent kern-class + ligature tables (lazy-loaded on first prewarm).
// The full kern MATRIX is NOT resident — on Literata-class fonts a single
Expand Down
14 changes: 6 additions & 8 deletions lib/Epub/Epub.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -488,8 +488,7 @@ class ContentsDocumentScanner final : public Print {
} // namespace

Epub::Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
cachePath = cachePathForFilePath(this->filepath, cacheDir);
migrateLegacyCachePath(cacheDir);
cachePath = resolveCachePathForFilePath(this->filepath, cacheDir);
}

std::string Epub::cachePathForFilePath(const std::string& filepath, const std::string& cacheDir) {
Expand All @@ -501,21 +500,23 @@ bool Epub::hasCache(const std::string& filepath, const std::string& cacheDir) {
return BookMetadataCache::exists(cachePathForFilePath(filepath, cacheDir));
}

void Epub::migrateLegacyCachePath(const std::string& cacheDir) const {
std::string Epub::resolveCachePathForFilePath(const std::string& filepath, const std::string& cacheDir) {
const std::string cachePath = cachePathForFilePath(filepath, cacheDir);
if (Storage.exists(cachePath.c_str())) {
return;
return cachePath;
}

const std::string legacyCachePath = legacyCachePathForFilePath(filepath, cacheDir);
if (legacyCachePath == cachePath || !Storage.exists(legacyCachePath.c_str())) {
return;
return cachePath;
}

if (Storage.rename(legacyCachePath.c_str(), cachePath.c_str())) {
LOG_INF("EBP", "Migrated legacy EPUB cache: %s -> %s", legacyCachePath.c_str(), cachePath.c_str());
} else {
LOG_ERR("EBP", "Failed to migrate legacy EPUB cache: %s -> %s", legacyCachePath.c_str(), cachePath.c_str());
}
return cachePath;
}

bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
Expand Down Expand Up @@ -1135,9 +1136,6 @@ bool Epub::loadMetadata(std::string& title, std::string& author, const bool allo
if (!metadataCache) {
LOG_ERR("EBP", "Could not allocate metadata cache reader");
}
} else if (!allowCachedMetadata && !clearCache()) {
LOG_ERR("EBP", "Could not invalidate stale metadata cache");
return false;
}

BookMetadataCache::BookMetadata metadata;
Expand Down
Loading
Loading