## Summary This PR introduces **Focus Reading**, a generic implementation of artificial fixation points (similar to Bionic Reading) designed to improve reading speed and focus by bolding the initial characters of words. This is achieved by dynamically bolding characters during indexing. <img width="500" alt="Focus Reading on X3" src="https://github.com/user-attachments/assets/94a632a5-82da-47be-957c-538b35bf84d9" /> ### Implementation Details #### Core Text Engine (`ParsedText`) - Modified `ParsedText::addWord` to implement a custom bolding algorithm. It uses a 45% ratio for bolding, with a minimum of 1 character and a maximum of 9. - UTF-8 Safety: Integrated `utf8NextCodepoint` to ensure character counting and string slicing occur at safe byte boundaries, preventing corruption of multi-byte characters (e.g., accented letters or smart quotes). - Intelligent Tokenization: This correctly identifies and separates "word" characters (letters, apostrophes, hyphens) from "non-word" characters (numbers, brackets, smart quotes). - Formatting Preservation: The logic ensures that punctuation is not "stolen" for the bolding count and that existing styles (like italics or underlines) are preserved across the bold/regular split. - Processing at indexing stage reduces CPU load at render-time and ensures layout/fit is unaffected. - Split details are tracked with `wordIsFocusSuffix`. After splitting and layout, suffixes are merged back into their preceding word entries to prevent a doubling of RAM usage. #### Settings and UI - Version Management: Bumped `SECTION_FILE_VERSION` to `21` - Global Settings: Added `focusReadingEnabled` to `CrossPointSettings`. - User Interface: Added a new toggle in the "Reader" section of the settings menu, positioned after the "Embedded Style" option. - Localization: Added the `STR_FOCUS_READING` string #### Plumbing - Plumbed the `focusReadingEnabled` boolean through `EpubReaderActivity`, `Section`, and `ChapterHtmlSlimParser` to ensure the user's setting reaches the `ParsedText` constructor during chapter indexing. ## Additional Context ### Files Changed - `lib/Epub/Epub/ParsedText.h / .cpp`: Core fixation logic and UTF-8 tokenization. - `lib/Epub/Epub/Section.h / .cpp`: Cache header updates and invalidation logic. - `lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h / .cpp`: Plumbing the setting to text blocks. - `src/CrossPointSettings.h`: Data persistence for the new setting. - `src/SettingsList.h`: UI toggle implementation. - `src/activities/reader/EpubReaderActivity.cpp`: Handling settings changes during reading sessions. - `lib/I18n/translations/*.yaml`: UI strings. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES**_
254 lines
14 KiB
C++
254 lines
14 KiB
C++
#pragma once
|
|
|
|
#include <HalTiltSensor.h>
|
|
#include <I18n.h>
|
|
#include <SdCardFontRegistry.h>
|
|
|
|
#include <algorithm>
|
|
#include <cstring>
|
|
#include <iterator>
|
|
#include <vector>
|
|
|
|
#include "CrossPointSettings.h"
|
|
#include "KOReaderCredentialStore.h"
|
|
#include "activities/settings/SettingsActivity.h"
|
|
|
|
// Build the font family setting dynamically. When registry is non-null, SD card fonts
|
|
// are appended after the built-in fonts. Otherwise only built-in fonts are listed.
|
|
inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
|
|
// Built-in font labels (StrId)
|
|
std::vector<StrId> enumValues = {StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC};
|
|
// Runtime string labels for SD card fonts
|
|
std::vector<std::string> enumStringValues;
|
|
|
|
// Reserve: first CrossPointSettings::BUILTIN_FONT_COUNT entries use StrId, rest use strings
|
|
if (registry) {
|
|
const auto& families = registry->getFamilies();
|
|
enumStringValues.reserve(families.size());
|
|
std::transform(families.begin(), families.end(), std::back_inserter(enumStringValues),
|
|
[](const SdCardFontFamilyInfo& f) { return f.name; });
|
|
}
|
|
|
|
// Capture the SD font count for the lambdas
|
|
const int sdFontCount = static_cast<int>(enumStringValues.size());
|
|
|
|
// Total option count = built-in + SD card families
|
|
// For the combined enumStringValues: we need all entries as strings (built-in names + SD names)
|
|
// The render code checks enumStringValues first, then enumValues. So we build enumStringValues
|
|
// with all options when SD fonts are present.
|
|
std::vector<std::string> allStringValues;
|
|
if (sdFontCount > 0) {
|
|
allStringValues.push_back(I18N.get(StrId::STR_NOTO_SERIF));
|
|
allStringValues.push_back(I18N.get(StrId::STR_NOTO_SANS));
|
|
allStringValues.push_back(I18N.get(StrId::STR_OPEN_DYSLEXIC));
|
|
allStringValues.insert(allStringValues.end(), enumStringValues.begin(), enumStringValues.end());
|
|
}
|
|
|
|
SettingInfo s;
|
|
s.nameId = StrId::STR_FONT_FAMILY;
|
|
s.type = SettingType::ENUM;
|
|
s.enumValues = std::move(enumValues);
|
|
s.enumStringValues = std::move(allStringValues);
|
|
s.key = "fontFamily";
|
|
s.category = StrId::STR_CAT_READER;
|
|
|
|
// Capture registry families by copy for the lambdas
|
|
std::vector<std::string> sdFamilyNames;
|
|
if (registry) {
|
|
const auto& families = registry->getFamilies();
|
|
sdFamilyNames.reserve(families.size());
|
|
std::transform(families.begin(), families.end(), std::back_inserter(sdFamilyNames),
|
|
[](const SdCardFontFamilyInfo& f) { return f.name; });
|
|
}
|
|
|
|
s.valueGetter = [sdFamilyNames]() -> uint8_t {
|
|
// If an SD card font is selected, find its index
|
|
if (SETTINGS.sdFontFamilyName[0] != '\0') {
|
|
for (int i = 0; i < static_cast<int>(sdFamilyNames.size()); i++) {
|
|
if (sdFamilyNames[i] == SETTINGS.sdFontFamilyName) {
|
|
return static_cast<uint8_t>(CrossPointSettings::BUILTIN_FONT_COUNT + i);
|
|
}
|
|
}
|
|
// SD font name not found in registry — fall through to built-in
|
|
}
|
|
return SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
|
|
};
|
|
|
|
s.valueSetter = [sdFamilyNames](uint8_t v) {
|
|
if (v < CrossPointSettings::BUILTIN_FONT_COUNT) {
|
|
SETTINGS.fontFamily = v;
|
|
SETTINGS.sdFontFamilyName[0] = '\0';
|
|
} else {
|
|
int sdIdx = v - CrossPointSettings::BUILTIN_FONT_COUNT;
|
|
if (sdIdx < static_cast<int>(sdFamilyNames.size())) {
|
|
strncpy(SETTINGS.sdFontFamilyName, sdFamilyNames[sdIdx].c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
|
|
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
|
|
}
|
|
}
|
|
};
|
|
|
|
return s;
|
|
}
|
|
|
|
// Shared settings list used by both the device settings UI and the web settings API.
|
|
// Each entry has a key (for JSON API) and category (for grouping).
|
|
// ACTION-type entries and entries without a key are device-only.
|
|
//
|
|
// The static list is constructed exactly once (master's optimization, #1086 +
|
|
// #1636) so the per-entry SettingInfo cost is paid once. When an
|
|
// SdCardFontRegistry is supplied AND has SD card fonts installed, the
|
|
// font-family entry is replaced in a per-call copy with a registry-aware
|
|
// version. Callers without SD fonts pay only a vector copy.
|
|
inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* registry = nullptr) {
|
|
static const std::vector<SettingInfo> baseList = [] {
|
|
std::vector<SettingInfo> v = {
|
|
// --- Display ---
|
|
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
|
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER, StrId::STR_NONE_OPT,
|
|
StrId::STR_COVER_CUSTOM},
|
|
"sleepScreen", StrId::STR_CAT_DISPLAY),
|
|
SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode,
|
|
{StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY),
|
|
SettingInfo::Enum(StrId::STR_SLEEP_COVER_FILTER, &CrossPointSettings::sleepScreenCoverFilter,
|
|
{StrId::STR_NONE_OPT, StrId::STR_FILTER_CONTRAST, StrId::STR_INVERTED},
|
|
"sleepScreenCoverFilter", StrId::STR_CAT_DISPLAY),
|
|
SettingInfo::Enum(StrId::STR_HIDE_BATTERY, &CrossPointSettings::hideBatteryPercentage,
|
|
{StrId::STR_NEVER, StrId::STR_IN_READER, StrId::STR_ALWAYS}, "hideBatteryPercentage",
|
|
StrId::STR_CAT_DISPLAY),
|
|
SettingInfo::Enum(
|
|
StrId::STR_REFRESH_FREQ, &CrossPointSettings::refreshFrequency,
|
|
{StrId::STR_PAGES_1, StrId::STR_PAGES_5, StrId::STR_PAGES_10, StrId::STR_PAGES_15, StrId::STR_PAGES_30},
|
|
"refreshFrequency", StrId::STR_CAT_DISPLAY),
|
|
SettingInfo::Enum(StrId::STR_UI_THEME, &CrossPointSettings::uiTheme,
|
|
{StrId::STR_THEME_CLASSIC, StrId::STR_THEME_LYRA, StrId::STR_THEME_LYRA_EXTENDED,
|
|
StrId::STR_THEME_ROUNDEDRAFF},
|
|
"uiTheme", StrId::STR_CAT_DISPLAY),
|
|
SettingInfo::Toggle(StrId::STR_SUNLIGHT_FADING_FIX, &CrossPointSettings::fadingFix, "fadingFix",
|
|
StrId::STR_CAT_DISPLAY),
|
|
|
|
// --- Reader ---
|
|
// Built-in font-family entry. Replaced per-call with a registry-aware
|
|
// version when SD fonts are installed.
|
|
SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,
|
|
{StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC}, "fontFamily",
|
|
StrId::STR_CAT_READER),
|
|
SettingInfo::Enum(StrId::STR_FONT_SIZE, &CrossPointSettings::fontSize,
|
|
{StrId::STR_SMALL, StrId::STR_MEDIUM, StrId::STR_LARGE, StrId::STR_X_LARGE}, "fontSize",
|
|
StrId::STR_CAT_READER),
|
|
SettingInfo::Enum(StrId::STR_LINE_SPACING, &CrossPointSettings::lineSpacing,
|
|
{StrId::STR_TIGHT, StrId::STR_NORMAL, StrId::STR_WIDE}, "lineSpacing", StrId::STR_CAT_READER),
|
|
SettingInfo::Value(StrId::STR_SCREEN_MARGIN, &CrossPointSettings::screenMargin, {5, 40, 5}, "screenMargin",
|
|
StrId::STR_CAT_READER),
|
|
SettingInfo::Enum(StrId::STR_PARA_ALIGNMENT, &CrossPointSettings::paragraphAlignment,
|
|
{StrId::STR_JUSTIFY, StrId::STR_ALIGN_LEFT, StrId::STR_CENTER, StrId::STR_ALIGN_RIGHT,
|
|
StrId::STR_BOOK_S_STYLE},
|
|
"paragraphAlignment", StrId::STR_CAT_READER),
|
|
SettingInfo::Toggle(StrId::STR_EMBEDDED_STYLE, &CrossPointSettings::embeddedStyle, "embeddedStyle",
|
|
StrId::STR_CAT_READER),
|
|
SettingInfo::Toggle(StrId::STR_FOCUS_READING, &CrossPointSettings::focusReadingEnabled, "focusReadingEnabled",
|
|
StrId::STR_CAT_READER),
|
|
SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled",
|
|
StrId::STR_CAT_READER),
|
|
SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
|
|
{StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW},
|
|
"orientation", StrId::STR_CAT_READER),
|
|
SettingInfo::Toggle(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing,
|
|
"extraParagraphSpacing", StrId::STR_CAT_READER),
|
|
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
|
|
StrId::STR_CAT_READER),
|
|
SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering,
|
|
{StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS},
|
|
"imageRendering", StrId::STR_CAT_READER),
|
|
// --- Controls ---
|
|
SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout,
|
|
{StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV}, "sideButtonLayout", StrId::STR_CAT_CONTROLS),
|
|
SettingInfo::Enum(StrId::STR_LONG_PRESS_BEHAVIOR, &CrossPointSettings::longPressButtonBehavior,
|
|
{StrId::STR_LONG_PRESS_BEHAVIOR_OFF, StrId::STR_LONG_PRESS_BEHAVIOR_SKIP,
|
|
StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION},
|
|
"longPressButtonBehavior", StrId::STR_CAT_CONTROLS),
|
|
SettingInfo::Enum(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
|
|
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH},
|
|
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
|
|
|
|
// --- System ---
|
|
SettingInfo::Enum(StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeout,
|
|
{StrId::STR_MIN_1, StrId::STR_MIN_5, StrId::STR_MIN_10, StrId::STR_MIN_15, StrId::STR_MIN_30},
|
|
"sleepTimeout", StrId::STR_CAT_SYSTEM),
|
|
SettingInfo::Toggle(StrId::STR_SHOW_HIDDEN_FILES, &CrossPointSettings::showHiddenFiles, "showHiddenFiles",
|
|
StrId::STR_CAT_SYSTEM),
|
|
|
|
// --- KOReader Sync (web-only, uses KOReaderCredentialStore) ---
|
|
SettingInfo::DynamicString(
|
|
StrId::STR_KOREADER_USERNAME, [] { return KOREADER_STORE.getUsername(); },
|
|
[](const std::string& v) {
|
|
KOREADER_STORE.setCredentials(v, KOREADER_STORE.getPassword());
|
|
KOREADER_STORE.saveToFile();
|
|
},
|
|
"koUsername", StrId::STR_KOREADER_SYNC),
|
|
SettingInfo::DynamicString(
|
|
StrId::STR_KOREADER_PASSWORD, [] { return KOREADER_STORE.getPassword(); },
|
|
[](const std::string& v) {
|
|
KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), v);
|
|
KOREADER_STORE.saveToFile();
|
|
},
|
|
"koPassword", StrId::STR_KOREADER_SYNC),
|
|
SettingInfo::DynamicString(
|
|
StrId::STR_SYNC_SERVER_URL, [] { return KOREADER_STORE.getServerUrl(); },
|
|
[](const std::string& v) {
|
|
KOREADER_STORE.setServerUrl(v);
|
|
KOREADER_STORE.saveToFile();
|
|
},
|
|
"koServerUrl", StrId::STR_KOREADER_SYNC),
|
|
SettingInfo::DynamicEnum(
|
|
StrId::STR_DOCUMENT_MATCHING, {StrId::STR_FILENAME, StrId::STR_BINARY},
|
|
[] { return static_cast<uint8_t>(KOREADER_STORE.getMatchMethod()); },
|
|
[](uint8_t v) {
|
|
KOREADER_STORE.setMatchMethod(static_cast<DocumentMatchMethod>(v));
|
|
KOREADER_STORE.saveToFile();
|
|
},
|
|
"koMatchMethod", StrId::STR_KOREADER_SYNC),
|
|
// --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) ---
|
|
SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount,
|
|
"statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR),
|
|
SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage,
|
|
"statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR),
|
|
SettingInfo::Enum(StrId::STR_PROGRESS_BAR, &CrossPointSettings::statusBarProgressBar,
|
|
{StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}, "statusBarProgressBar",
|
|
StrId::STR_CUSTOMISE_STATUS_BAR),
|
|
SettingInfo::Enum(StrId::STR_PROGRESS_BAR_THICKNESS, &CrossPointSettings::statusBarProgressBarThickness,
|
|
{StrId::STR_PROGRESS_BAR_THIN, StrId::STR_PROGRESS_BAR_MEDIUM, StrId::STR_PROGRESS_BAR_THICK},
|
|
"statusBarProgressBarThickness", StrId::STR_CUSTOMISE_STATUS_BAR),
|
|
SettingInfo::Enum(StrId::STR_TITLE, &CrossPointSettings::statusBarTitle,
|
|
{StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}, "statusBarTitle",
|
|
StrId::STR_CUSTOMISE_STATUS_BAR),
|
|
SettingInfo::Toggle(StrId::STR_BATTERY, &CrossPointSettings::statusBarBattery, "statusBarBattery",
|
|
StrId::STR_CUSTOMISE_STATUS_BAR),
|
|
SettingInfo::Enum(StrId::STR_XTC_STATUS_BAR, &CrossPointSettings::xtcStatusBarMode,
|
|
{StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}, "xtcStatusBarMode",
|
|
StrId::STR_CUSTOMISE_STATUS_BAR),
|
|
};
|
|
// Only show tilt page turn setting when the QMI8658 IMU is present (X3)
|
|
if (halTiltSensor.isAvailable()) {
|
|
// Insert after the short power button setting (end of Controls section)
|
|
for (auto it = v.begin(); it != v.end(); ++it) {
|
|
if (it->nameId == StrId::STR_SHORT_PWR_BTN) {
|
|
v.insert(it + 1, SettingInfo::Enum(StrId::STR_TILT_PAGE_TURN, &CrossPointSettings::tiltPageTurn,
|
|
{StrId::STR_STATE_OFF, StrId::STR_NORMAL, StrId::STR_INVERTED},
|
|
"tiltPageTurn", StrId::STR_CAT_CONTROLS));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return v;
|
|
}();
|
|
|
|
std::vector<SettingInfo> v = baseList;
|
|
if (registry && registry->getFamilyCount() > 0) {
|
|
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_FONT_FAMILY; });
|
|
if (it != v.end()) {
|
|
*it = buildFontFamilySetting(registry);
|
|
}
|
|
}
|
|
return v;
|
|
}
|