diff --git a/README.md b/README.md index 27fcba7..56d77a1 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,28 @@ + + + + + +
+ # xteink-plugins A plugin system for customizing and extending [CrossPoint Reader](https://github.com/crosspoint-reader/crosspoint-reader) firmware on your xteink device. Plugins are applied as source-level patches before the firmware is compiled and flashed. -![Smaller Fonts plugin settings tab on the xteink device](screenshot.jpg) - ## Plugins +### Dark Mode + +Adds a **Dark Mode** option to the Plugins settings tab. When enabled, the screen is inverted after each page render, producing white-on-black text across all reader formats (EPUB, TXT, and XTC). + +| State | Effect | +|-------|--------| +| **Disabled** | Normal display (default) | +| **Enabled** | Screen inverted — white text on black background | + ### Smaller Fonts -Adds a **Plugins** settings tab to CrossPoint with a "Smaller Fonts" option. When enabled, your chosen reader font is transparently substituted with a smaller variant — no need to change your font preference. +Adds a **Smaller Fonts** option to the Plugins settings tab. When enabled, your chosen reader font is transparently substituted with a smaller variant — no need to change your font preference. | Mode | Effect | |------|--------| @@ -48,6 +62,10 @@ The installer will: xteink-plugins/ ├── install.py # Interactive installer: clone → patch → build → flash └── plugins/ + ├── darkmode/ + │ ├── patch.py # Patch script applied to the CrossPoint source + │ ├── DarkModePlugin.h/.cpp # Dark mode state and screen inversion logic + │ └── DarkModeSettingsPage.h/.cpp # Settings UI activity └── smallerfonts/ ├── patch.py # Patch script applied to the CrossPoint source ├── SmallerFontsPlugin.h/.cpp # Font resolution logic diff --git a/plugins/.DS_Store b/plugins/.DS_Store deleted file mode 100644 index 9ba66bc..0000000 Binary files a/plugins/.DS_Store and /dev/null differ diff --git a/plugins/darkmode/DarkModePlugin.cpp b/plugins/darkmode/DarkModePlugin.cpp new file mode 100644 index 0000000..3a25c9f --- /dev/null +++ b/plugins/darkmode/DarkModePlugin.cpp @@ -0,0 +1,16 @@ +#include "DarkModePlugin.h" + +namespace DarkModePlugin { + +const char* stateName(DarkModeState state) { + switch (state) { + case DarkModeState::MODE_ON: return "Enabled"; + default: return "Disabled"; + } +} + +bool isDarkMode(DarkModeState state) { + return state == DarkModeState::MODE_ON; +} + +} diff --git a/plugins/darkmode/DarkModePlugin.h b/plugins/darkmode/DarkModePlugin.h new file mode 100644 index 0000000..1d4675a --- /dev/null +++ b/plugins/darkmode/DarkModePlugin.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +enum class DarkModeState : uint8_t { + MODE_OFF = 0, + MODE_ON = 1, +}; + +namespace DarkModePlugin { + const char* stateName(DarkModeState state); + bool isDarkMode(DarkModeState state); +} diff --git a/plugins/darkmode/DarkModeSettingsPage.cpp b/plugins/darkmode/DarkModeSettingsPage.cpp new file mode 100644 index 0000000..1154f69 --- /dev/null +++ b/plugins/darkmode/DarkModeSettingsPage.cpp @@ -0,0 +1,22 @@ +#include "DarkModeSettingsActivity.h" +#include "fontIds.h" + +void DarkModeSettingsActivity::render(GfxRenderer& renderer, DarkModeState currentState) { + const int LABEL_X = 10; + const int VALUE_X = 160; + const int ROW_Y = 60; + renderer.drawText(UI_12_FONT_ID, LABEL_X, ROW_Y, "Dark Mode"); + renderer.drawText(UI_12_FONT_ID, VALUE_X, ROW_Y, DarkModePlugin::stateName(currentState)); +} + +DarkModeState DarkModeSettingsActivity::handleNext(DarkModeState currentState) { + uint8_t v = static_cast(currentState); + if (v < 1) v++; + return static_cast(v); +} + +DarkModeState DarkModeSettingsActivity::handlePrev(DarkModeState currentState) { + uint8_t v = static_cast(currentState); + if (v > 0) v--; + return static_cast(v); +} diff --git a/plugins/darkmode/DarkModeSettingsPage.h b/plugins/darkmode/DarkModeSettingsPage.h new file mode 100644 index 0000000..913a8b5 --- /dev/null +++ b/plugins/darkmode/DarkModeSettingsPage.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include "DarkModePlugin.h" + +class DarkModeSettingsActivity { +public: + static void render(GfxRenderer& renderer, DarkModeState currentState); + static DarkModeState handleNext(DarkModeState currentState); + static DarkModeState handlePrev(DarkModeState currentState); +}; diff --git a/plugins/darkmode/patch.py b/plugins/darkmode/patch.py new file mode 100644 index 0000000..eaf1801 --- /dev/null +++ b/plugins/darkmode/patch.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 + +import os +import sys +import glob +import shutil + + +def read_file(path): + with open(path, "r", encoding="utf-8") as f: + return f.read() + +def write_file(path, content): + with open(path, "w", encoding="utf-8") as f: + f.write(content) + +def find_first(filename, repo_dir): + results = glob.glob(os.path.join(repo_dir, "**", filename), recursive=True) + if not results: + sys.exit(f"ERROR: Could not locate {filename} in {repo_dir}") + return results[0] + + +def copy_plugin_sources(plugin_dir, repo_dir): + dest = os.path.join(repo_dir, "src", "activities", "settings") + os.makedirs(dest, exist_ok=True) + + skip = {"DarkModeSettingsPage.h", "DarkModeSettingsPage.cpp"} + + for fname in os.listdir(plugin_dir): + if fname.endswith((".h", ".cpp", ".hpp")) and fname not in skip: + src = os.path.join(plugin_dir, fname) + dst = os.path.join(dest, fname) + shutil.copy2(src, dst) + print(f" ✓ {fname}") + + +def patch_cross_point_settings_h(repo_dir): + path = find_first("CrossPointSettings.h", repo_dir) + content = read_file(path) + + if "darkModeState" in content: + print(" CrossPointSettings.h already patched, skipping.") + return + + content = content.replace( + '#pragma once\n#include ', + '#pragma once\n#include \n#include "activities/settings/DarkModePlugin.h"' + ) + content = content.replace( + ' uint8_t imageRendering = IMAGES_DISPLAY;', + ' uint8_t imageRendering = IMAGES_DISPLAY;\n uint8_t darkModeState = 0;' + ) + + write_file(path, content) + print(" CrossPointSettings.h patched.") + + +def patch_settings_h(repo_dir): + path = find_first("SettingsActivity.h", repo_dir) + content = read_file(path) + + already_has_plugins_tab = "categoryCount = 5" in content + already_has_plugins_vec = "pluginsSettings" in content + + if already_has_plugins_tab and already_has_plugins_vec: + print(" SettingsActivity.h already patched, skipping.") + return + + if '"DarkModePlugin.h"' not in content: + content = content.replace( + '#include ', + '#include \n#include "DarkModePlugin.h"' + ) + + if not already_has_plugins_tab: + content = content.replace( + 'static constexpr int categoryCount = 4;', + 'static constexpr int categoryCount = 5;' + ) + + if not already_has_plugins_vec: + content = content.replace( + ' std::vector systemSettings;', + ' std::vector systemSettings;\n std::vector pluginsSettings;' + ) + + write_file(path, content) + print(" SettingsActivity.h patched.") + + +def patch_settings_cpp(repo_dir): + path = find_first("SettingsActivity.cpp", repo_dir) + content = read_file(path) + + if '"DarkModePlugin.h"' not in content: + content = content.replace( + '#include "SettingsList.h"', + '#include "SettingsList.h"\n#include "DarkModePlugin.h"' + ) + + if "STR_NONE_OPT" not in content: + content = content.replace( + 'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n' + ' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};', + 'const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,\n' + ' StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,\n' + ' StrId::STR_NONE_OPT};' + ) + + if "pluginsSettings.clear();" not in content: + content = content.replace( + ' systemSettings.clear();', + ' systemSettings.clear();\n pluginsSettings.clear();' + ) + + plugins_block = ( + ' pluginsSettings.push_back(SettingInfo::Enum(\n' + ' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n' + ' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n' + ' ));\n' + ' pluginsSettings.push_back(SettingInfo::Enum(\n' + ' StrId::STR_NONE_OPT, &CrossPointSettings::smallerFontsMode,\n' + ' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "smallerFontsMode"\n' + ' ));\n' + ) + + broken_block = ( + ' pluginsSettings.push_back(SettingInfo::Enum(\n' + ' StrId::STR_NONE_OPT,\n' + ' &CrossPointSettings::darkModeState,\n' + ' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT},\n' + ' "darkModeState"\n' + ' ));\n' + ) + + if broken_block in content: + content = content.replace(broken_block, plugins_block) + elif '"darkModeState"' in content and '"smallerFontsMode"' not in content: + content = content.replace( + ' pluginsSettings.push_back(SettingInfo::Enum(\n' + ' StrId::STR_NONE_OPT, &CrossPointSettings::darkModeState,\n' + ' {StrId::STR_NONE_OPT, StrId::STR_NONE_OPT}, "darkModeState"\n' + ' ));\n', + plugins_block + ) + elif '"darkModeState"' not in content: + content = content.replace( + ' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n', + ' readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));\n' + + plugins_block + ) + + if 'case 4:\n currentSettings = &pluginsSettings;' not in content: + content = content.replace( + ' case 3:\n currentSettings = &systemSettings;\n break;', + ' case 3:\n currentSettings = &systemSettings;\n break;\n' + ' case 4:\n currentSettings = &pluginsSettings;\n break;' + ) + + if '"Plugins"' not in content: + content = content.replace( + ' std::vector tabs;\n' + ' tabs.reserve(categoryCount);\n' + ' for (int i = 0; i < categoryCount; i++) {\n' + ' tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});\n' + ' }', + ' std::vector tabs;\n' + ' tabs.reserve(categoryCount);\n' + ' for (int i = 0; i < categoryCount; i++) {\n' + ' const char* tabLabel = (i == 4) ? "Plugins" : I18N.get(categoryNames[i]);\n' + ' tabs.push_back({tabLabel, selectedCategoryIndex == i});\n' + ' }' + ) + + if 'nextCatLabel' not in content: + content = content.replace( + ' const auto confirmLabel = (selectedSettingIndex == 0)\n' + ' ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])\n' + ' : tr(STR_TOGGLE);', + ' const int nextCatIndex = (selectedCategoryIndex + 1) % categoryCount;\n' + ' const char* nextCatLabel = (nextCatIndex == 4) ? "Plugins" : I18N.get(categoryNames[nextCatIndex]);\n' + ' const auto confirmLabel = (selectedSettingIndex == 0) ? nextCatLabel : tr(STR_TOGGLE);' + ) + + if 'selectedCategoryIndex == 4' not in content: + content = content.replace( + ' [&settings](int index) { return std::string(I18N.get(settings[index].nameId)); },', + ' [&settings, this](int index) -> std::string {\n' + ' if (selectedCategoryIndex == 4) {\n' + ' const auto& s = settings[index];\n' + ' if (s.key && std::string(s.key) == "darkModeState") return "Dark Mode";\n' + ' if (s.key && std::string(s.key) == "smallerFontsMode") return "Smaller Fonts";\n' + ' }\n' + ' return std::string(I18N.get(settings[index].nameId));\n' + ' },' + ) + + # Value rendering — handle all states: neither, only dark, both + both_present = 'DarkModePlugin::stateName' in content and 'SmallerFontsPlugin::modeName' in content + only_dark = 'DarkModePlugin::stateName' in content and 'SmallerFontsPlugin::modeName' not in content + neither = 'DarkModePlugin::stateName' not in content + + if only_dark: + content = content.replace( + ' if (setting.key && std::string(setting.key) == "darkModeState") {\n' + ' valueText = DarkModePlugin::stateName(static_cast(value));\n' + ' } else {\n' + ' valueText = I18N.get(setting.enumValues[value]);\n' + ' }', + ' if (setting.key && std::string(setting.key) == "darkModeState") {\n' + ' valueText = DarkModePlugin::stateName(static_cast(value));\n' + ' } else if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n' + ' valueText = SmallerFontsPlugin::modeName(static_cast(value));\n' + ' } else {\n' + ' valueText = I18N.get(setting.enumValues[value]);\n' + ' }' + ) + elif neither: + if 'SmallerFontsPlugin::modeName' in content: + content = content.replace( + ' if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n' + ' valueText = SmallerFontsPlugin::modeName(static_cast(value));\n' + ' } else {\n' + ' valueText = I18N.get(setting.enumValues[value]);\n' + ' }', + ' if (setting.key && std::string(setting.key) == "darkModeState") {\n' + ' valueText = DarkModePlugin::stateName(static_cast(value));\n' + ' } else if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n' + ' valueText = SmallerFontsPlugin::modeName(static_cast(value));\n' + ' } else {\n' + ' valueText = I18N.get(setting.enumValues[value]);\n' + ' }' + ) + else: + content = content.replace( + ' valueText = I18N.get(setting.enumValues[value]);', + ' if (setting.key && std::string(setting.key) == "darkModeState") {\n' + ' valueText = DarkModePlugin::stateName(static_cast(value));\n' + ' } else if (setting.key && std::string(setting.key) == "smallerFontsMode") {\n' + ' valueText = SmallerFontsPlugin::modeName(static_cast(value));\n' + ' } else {\n' + ' valueText = I18N.get(setting.enumValues[value]);\n' + ' }' + ) + + write_file(path, content) + print(" SettingsActivity.cpp patched.") + +def patch_epub_reader(repo_dir): + path = find_first("EpubReaderActivity.cpp", repo_dir) + content = read_file(path) + + if "isDarkMode" in content: + print(" EpubReaderActivity.cpp already patched, skipping.") + return + + content = content.replace( + '#include "EpubReaderActivity.h"', + '#include "EpubReaderActivity.h"\n#include "activities/settings/DarkModePlugin.h"' + ) + + content = content.replace( + ' page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);\n' + ' renderStatusBar();\n' + ' fcm->logStats("bw_render");\n', + ' page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);\n' + ' renderStatusBar();\n' + ' if (DarkModePlugin::isDarkMode(static_cast(SETTINGS.darkModeState))) renderer.invertScreen();\n' + ' fcm->logStats("bw_render");\n' + ) + + write_file(path, content) + print(" EpubReaderActivity.cpp patched.") + + +def patch_txt_reader(repo_dir): + path = find_first("TxtReaderActivity.cpp", repo_dir) + content = read_file(path) + + if "isDarkMode" in content: + print(" TxtReaderActivity.cpp already patched, skipping.") + return + + content = content.replace( + '#include "TxtReaderActivity.h"', + '#include "TxtReaderActivity.h"\n#include "activities/settings/DarkModePlugin.h"' + ) + + content = content.replace( + ' renderLines();\n' + ' renderStatusBar();\n' + '\n' + ' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);', + ' renderLines();\n' + ' renderStatusBar();\n' + ' if (DarkModePlugin::isDarkMode(static_cast(SETTINGS.darkModeState))) renderer.invertScreen();\n' + '\n' + ' ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);' + ) + + write_file(path, content) + print(" TxtReaderActivity.cpp patched.") + + +def patch_xtc_reader(repo_dir): + path = find_first("XtcReaderActivity.cpp", repo_dir) + content = read_file(path) + + if "isDarkMode" in content: + print(" XtcReaderActivity.cpp already patched, skipping.") + return + + content = content.replace( + '#include "XtcReaderActivity.h"', + '#include "XtcReaderActivity.h"\n#include "activities/settings/DarkModePlugin.h"' + ) + + content = content.replace( + ' if (pagesUntilFullRefresh <= 1) {\n' + ' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n' + ' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n' + ' } else {\n' + ' renderer.displayBuffer();\n' + ' pagesUntilFullRefresh--;\n' + ' }\n' + '\n' + ' // Pass 2: LSB buffer', + ' if (DarkModePlugin::isDarkMode(static_cast(SETTINGS.darkModeState))) renderer.invertScreen();\n' + ' if (pagesUntilFullRefresh <= 1) {\n' + ' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n' + ' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n' + ' } else {\n' + ' renderer.displayBuffer();\n' + ' pagesUntilFullRefresh--;\n' + ' }\n' + '\n' + ' // Pass 2: LSB buffer' + ) + + content = content.replace( + ' if (pagesUntilFullRefresh <= 1) {\n' + ' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n' + ' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n' + ' } else {\n' + ' renderer.displayBuffer();\n' + ' pagesUntilFullRefresh--;\n' + ' }\n' + '\n' + ' LOG_DBG("XTR"', + ' if (DarkModePlugin::isDarkMode(static_cast(SETTINGS.darkModeState))) renderer.invertScreen();\n' + ' if (pagesUntilFullRefresh <= 1) {\n' + ' renderer.displayBuffer(HalDisplay::HALF_REFRESH);\n' + ' pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();\n' + ' } else {\n' + ' renderer.displayBuffer();\n' + ' pagesUntilFullRefresh--;\n' + ' }\n' + '\n' + ' LOG_DBG("XTR"' + ) + + write_file(path, content) + print(" XtcReaderActivity.cpp patched.") + + +def patch(repo_dir: str): + plugin_dir = os.path.dirname(os.path.abspath(__file__)) + + print(" Copying plugin sources...") + copy_plugin_sources(plugin_dir, repo_dir) + + print(" Patching CrossPointSettings.h...") + patch_cross_point_settings_h(repo_dir) + + print(" Patching SettingsActivity.h...") + patch_settings_h(repo_dir) + + print(" Patching SettingsActivity.cpp...") + patch_settings_cpp(repo_dir) + + print(" Patching EpubReaderActivity.cpp...") + patch_epub_reader(repo_dir) + + print(" Patching TxtReaderActivity.cpp...") + patch_txt_reader(repo_dir) + + print(" Patching XtcReaderActivity.cpp...") + patch_xtc_reader(repo_dir) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + sys.exit("Usage: python patch.py ") + patch(sys.argv[1]) diff --git a/screenshot.jpg b/screenshot.jpg deleted file mode 100644 index 114c482..0000000 Binary files a/screenshot.jpg and /dev/null differ diff --git a/screenshot1.jpg b/screenshot1.jpg new file mode 100644 index 0000000..1f96957 Binary files /dev/null and b/screenshot1.jpg differ diff --git a/screenshot2.jpg b/screenshot2.jpg new file mode 100644 index 0000000..09ca0b4 Binary files /dev/null and b/screenshot2.jpg differ