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.
-
-
## 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