Merge branch 'master' into hyphenation-v2

This commit is contained in:
Arthur Tazhitdinov
2026-01-03 15:46:56 +05:00
175 changed files with 264382 additions and 22287 deletions

View File

@@ -1,25 +1,27 @@
#include "CrossPointSettings.h"
#include <FsHelpers.h>
#include <HardwareSerial.h>
#include <SD.h>
#include <SDCardManager.h>
#include <Serialization.h>
#include "fontIds.h"
// Initialize the static instance
CrossPointSettings CrossPointSettings::instance;
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
constexpr uint8_t SETTINGS_COUNT = 4;
// Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 14;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace
bool CrossPointSettings::saveToFile() const {
// Make sure the directory exists
SD.mkdir("/.crosspoint");
SdMan.mkdir("/.crosspoint");
File outputFile;
if (!FsHelpers::openFileForWrite("CPS", SETTINGS_FILE, outputFile)) {
FsFile outputFile;
if (!SdMan.openFileForWrite("CPS", SETTINGS_FILE, outputFile)) {
return false;
}
@@ -28,6 +30,16 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, sleepScreen);
serialization::writePod(outputFile, extraParagraphSpacing);
serialization::writePod(outputFile, shortPwrBtn);
serialization::writePod(outputFile, statusBar);
serialization::writePod(outputFile, orientation);
serialization::writePod(outputFile, frontButtonLayout);
serialization::writePod(outputFile, sideButtonLayout);
serialization::writePod(outputFile, fontFamily);
serialization::writePod(outputFile, fontSize);
serialization::writePod(outputFile, lineSpacing);
serialization::writePod(outputFile, paragraphAlignment);
serialization::writePod(outputFile, sleepTimeout);
serialization::writePod(outputFile, refreshFrequency);
serialization::writePod(outputFile, hyphenationEnabled);
outputFile.close();
@@ -36,8 +48,8 @@ bool CrossPointSettings::saveToFile() const {
}
bool CrossPointSettings::loadFromFile() {
File inputFile;
if (!FsHelpers::openFileForRead("CPS", SETTINGS_FILE, inputFile)) {
FsFile inputFile;
if (!SdMan.openFileForRead("CPS", SETTINGS_FILE, inputFile)) {
return false;
}
@@ -52,7 +64,7 @@ bool CrossPointSettings::loadFromFile() {
uint8_t fileSettingsCount = 0;
serialization::readPod(inputFile, fileSettingsCount);
// load settings that exist
// load settings that exist (support older files with fewer fields)
uint8_t settingsRead = 0;
do {
serialization::readPod(inputFile, sleepScreen);
@@ -61,6 +73,26 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, shortPwrBtn);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, statusBar);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, orientation);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, frontButtonLayout);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, sideButtonLayout);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, fontFamily);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, fontSize);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, lineSpacing);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, paragraphAlignment);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, sleepTimeout);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, refreshFrequency);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, hyphenationEnabled);
if (++settingsRead >= fileSettingsCount) break;
} while (false);
@@ -69,3 +101,113 @@ bool CrossPointSettings::loadFromFile() {
Serial.printf("[%lu] [CPS] Settings loaded from file\n", millis());
return true;
}
float CrossPointSettings::getReaderLineCompression() const {
switch (fontFamily) {
case BOOKERLY:
default:
switch (lineSpacing) {
case TIGHT:
return 0.95f;
case NORMAL:
default:
return 1.0f;
case WIDE:
return 1.1f;
}
case NOTOSANS:
switch (lineSpacing) {
case TIGHT:
return 0.90f;
case NORMAL:
default:
return 0.95f;
case WIDE:
return 1.0f;
}
case OPENDYSLEXIC:
switch (lineSpacing) {
case TIGHT:
return 0.90f;
case NORMAL:
default:
return 0.95f;
case WIDE:
return 1.0f;
}
}
}
unsigned long CrossPointSettings::getSleepTimeoutMs() const {
switch (sleepTimeout) {
case SLEEP_1_MIN:
return 1UL * 60 * 1000;
case SLEEP_5_MIN:
return 5UL * 60 * 1000;
case SLEEP_10_MIN:
default:
return 10UL * 60 * 1000;
case SLEEP_15_MIN:
return 15UL * 60 * 1000;
case SLEEP_30_MIN:
return 30UL * 60 * 1000;
}
}
int CrossPointSettings::getRefreshFrequency() const {
switch (refreshFrequency) {
case REFRESH_1:
return 1;
case REFRESH_5:
return 5;
case REFRESH_10:
return 10;
case REFRESH_15:
default:
return 15;
case REFRESH_30:
return 30;
}
}
int CrossPointSettings::getReaderFontId() const {
switch (fontFamily) {
case BOOKERLY:
default:
switch (fontSize) {
case SMALL:
return BOOKERLY_12_FONT_ID;
case MEDIUM:
default:
return BOOKERLY_14_FONT_ID;
case LARGE:
return BOOKERLY_16_FONT_ID;
case EXTRA_LARGE:
return BOOKERLY_18_FONT_ID;
}
case NOTOSANS:
switch (fontSize) {
case SMALL:
return NOTOSANS_12_FONT_ID;
case MEDIUM:
default:
return NOTOSANS_14_FONT_ID;
case LARGE:
return NOTOSANS_16_FONT_ID;
case EXTRA_LARGE:
return NOTOSANS_18_FONT_ID;
}
case OPENDYSLEXIC:
switch (fontSize) {
case SMALL:
return OPENDYSLEXIC_8_FONT_ID;
case MEDIUM:
default:
return OPENDYSLEXIC_10_FONT_ID;
case LARGE:
return OPENDYSLEXIC_12_FONT_ID;
case EXTRA_LARGE:
return OPENDYSLEXIC_14_FONT_ID;
}
}
}

View File

@@ -18,12 +18,62 @@ class CrossPointSettings {
// Should match with SettingsActivity text
enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3 };
// Status bar display type enum
enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 };
enum ORIENTATION {
PORTRAIT = 0, // 480x800 logical coordinates (current default)
LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
INVERTED = 2, // 480x800 logical coordinates, inverted
LANDSCAPE_CCW = 3 // 800x480 logical coordinates, native panel orientation
};
// Front button layout options
// Default: Back, Confirm, Left, Right
// Swapped: Left, Right, Back, Confirm
enum FRONT_BUTTON_LAYOUT { BACK_CONFIRM_LEFT_RIGHT = 0, LEFT_RIGHT_BACK_CONFIRM = 1, LEFT_BACK_CONFIRM_RIGHT = 2 };
// Side button layout options
// Default: Previous, Next
// Swapped: Next, Previous
enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1 };
// Font family options
enum FONT_FAMILY { BOOKERLY = 0, NOTOSANS = 1, OPENDYSLEXIC = 2 };
// Font size options
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 };
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 };
enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3 };
// Auto-sleep timeout options (in minutes)
enum SLEEP_TIMEOUT { SLEEP_1_MIN = 0, SLEEP_5_MIN = 1, SLEEP_10_MIN = 2, SLEEP_15_MIN = 3, SLEEP_30_MIN = 4 };
// E-ink refresh frequency (pages between full refreshes)
enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 };
// Sleep screen settings
uint8_t sleepScreen = DARK;
// Status bar settings
uint8_t statusBar = FULL;
// Text rendering settings
uint8_t extraParagraphSpacing = 1;
// Duration of the power button press
uint8_t shortPwrBtn = 0;
// EPUB reading orientation settings
// 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise
uint8_t orientation = PORTRAIT;
// Button layouts
uint8_t frontButtonLayout = BACK_CONFIRM_LEFT_RIGHT;
uint8_t sideButtonLayout = PREV_NEXT;
// Reader font settings
uint8_t fontFamily = BOOKERLY;
uint8_t fontSize = MEDIUM;
uint8_t lineSpacing = NORMAL;
uint8_t paragraphAlignment = JUSTIFIED;
// Auto-sleep timeout setting (default 10 minutes)
uint8_t sleepTimeout = SLEEP_10_MIN;
// E-ink refresh frequency (default 15 pages)
uint8_t refreshFrequency = REFRESH_15;
uint8_t hyphenationEnabled = 0;
~CrossPointSettings() = default;
@@ -31,10 +81,15 @@ class CrossPointSettings {
// Get singleton instance
static CrossPointSettings& getInstance() { return instance; }
uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 500; }
uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 400; }
int getReaderFontId() const;
bool saveToFile() const;
bool loadFromFile();
float getReaderLineCompression() const;
unsigned long getSleepTimeoutMs() const;
int getRefreshFrequency() const;
};
// Helper macro to access settings

View File

@@ -1,7 +1,7 @@
#include "CrossPointState.h"
#include <FsHelpers.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <Serialization.h>
namespace {
@@ -12,8 +12,8 @@ constexpr char STATE_FILE[] = "/.crosspoint/state.bin";
CrossPointState CrossPointState::instance;
bool CrossPointState::saveToFile() const {
File outputFile;
if (!FsHelpers::openFileForWrite("CPS", STATE_FILE, outputFile)) {
FsFile outputFile;
if (!SdMan.openFileForWrite("CPS", STATE_FILE, outputFile)) {
return false;
}
@@ -24,8 +24,8 @@ bool CrossPointState::saveToFile() const {
}
bool CrossPointState::loadFromFile() {
File inputFile;
if (!FsHelpers::openFileForRead("CPS", STATE_FILE, inputFile)) {
FsFile inputFile;
if (!SdMan.openFileForRead("CPS", STATE_FILE, inputFile)) {
return false;
}

100
src/MappedInputManager.cpp Normal file
View File

@@ -0,0 +1,100 @@
#include "MappedInputManager.h"
#include "CrossPointSettings.h"
decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button button) const {
const auto frontLayout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
const auto sideLayout = static_cast<CrossPointSettings::SIDE_BUTTON_LAYOUT>(SETTINGS.sideButtonLayout);
switch (button) {
case Button::Back:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_LEFT;
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_CONFIRM;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_BACK;
}
case Button::Confirm:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_RIGHT;
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_LEFT;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_CONFIRM;
}
case Button::Left:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_BACK;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_LEFT;
}
case Button::Right:
switch (frontLayout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_CONFIRM;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
default:
return InputManager::BTN_RIGHT;
}
case Button::Up:
return InputManager::BTN_UP;
case Button::Down:
return InputManager::BTN_DOWN;
case Button::Power:
return InputManager::BTN_POWER;
case Button::PageBack:
switch (sideLayout) {
case CrossPointSettings::NEXT_PREV:
return InputManager::BTN_DOWN;
case CrossPointSettings::PREV_NEXT:
default:
return InputManager::BTN_UP;
}
case Button::PageForward:
switch (sideLayout) {
case CrossPointSettings::NEXT_PREV:
return InputManager::BTN_UP;
case CrossPointSettings::PREV_NEXT:
default:
return InputManager::BTN_DOWN;
}
}
return InputManager::BTN_BACK;
}
bool MappedInputManager::wasPressed(const Button button) const { return inputManager.wasPressed(mapButton(button)); }
bool MappedInputManager::wasReleased(const Button button) const { return inputManager.wasReleased(mapButton(button)); }
bool MappedInputManager::isPressed(const Button button) const { return inputManager.isPressed(mapButton(button)); }
bool MappedInputManager::wasAnyPressed() const { return inputManager.wasAnyPressed(); }
bool MappedInputManager::wasAnyReleased() const { return inputManager.wasAnyReleased(); }
unsigned long MappedInputManager::getHeldTime() const { return inputManager.getHeldTime(); }
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
const char* next) const {
const auto layout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
switch (layout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return {previous, next, back, confirm};
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return {previous, back, confirm, next};
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return {back, confirm, previous, next};
}
}

29
src/MappedInputManager.h Normal file
View File

@@ -0,0 +1,29 @@
#pragma once
#include <InputManager.h>
class MappedInputManager {
public:
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward };
struct Labels {
const char* btn1;
const char* btn2;
const char* btn3;
const char* btn4;
};
explicit MappedInputManager(InputManager& inputManager) : inputManager(inputManager) {}
bool wasPressed(Button button) const;
bool wasReleased(Button button) const;
bool isPressed(Button button) const;
bool wasAnyPressed() const;
bool wasAnyReleased() const;
unsigned long getHeldTime() const;
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
private:
InputManager& inputManager;
decltype(InputManager::BTN_BACK) mapButton(Button button) const;
};

41
src/ScreenComponents.cpp Normal file
View File

@@ -0,0 +1,41 @@
#include "ScreenComponents.h"
#include <GfxRenderer.h>
#include <string>
#include "Battery.h"
#include "fontIds.h"
void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top) {
// Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%";
renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 10;
const int x = left;
const int y = top + 8;
// Top line
renderer.drawLine(x, y, x + batteryWidth - 4, y);
// Bottom line
renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
// Left line
renderer.drawLine(x, y, x, y + batteryHeight - 1);
// Battery end
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow
}
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
}

8
src/ScreenComponents.h Normal file
View File

@@ -0,0 +1,8 @@
#pragma once
class GfxRenderer;
class ScreenComponents {
public:
static void drawBattery(const GfxRenderer& renderer, int left, int top);
};

View File

@@ -1,8 +1,7 @@
#include "WifiCredentialStore.h"
#include <FsHelpers.h>
#include <HardwareSerial.h>
#include <SD.h>
#include <SDCardManager.h>
#include <Serialization.h>
// Initialize the static instance
@@ -30,10 +29,10 @@ void WifiCredentialStore::obfuscate(std::string& data) const {
bool WifiCredentialStore::saveToFile() const {
// Make sure the directory exists
SD.mkdir("/.crosspoint");
SdMan.mkdir("/.crosspoint");
File file;
if (!FsHelpers::openFileForWrite("WCS", WIFI_FILE, file)) {
FsFile file;
if (!SdMan.openFileForWrite("WCS", WIFI_FILE, file)) {
return false;
}
@@ -60,8 +59,8 @@ bool WifiCredentialStore::saveToFile() const {
}
bool WifiCredentialStore::loadFromFile() {
File file;
if (!FsHelpers::openFileForRead("WCS", WIFI_FILE, file)) {
FsFile file;
if (!SdMan.openFileForRead("WCS", WIFI_FILE, file)) {
return false;
}

View File

@@ -5,21 +5,22 @@
#include <string>
#include <utility>
class InputManager;
class MappedInputManager;
class GfxRenderer;
class Activity {
protected:
std::string name;
GfxRenderer& renderer;
InputManager& inputManager;
MappedInputManager& mappedInput;
public:
explicit Activity(std::string name, GfxRenderer& renderer, InputManager& inputManager)
: name(std::move(name)), renderer(renderer), inputManager(inputManager) {}
explicit Activity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput)
: name(std::move(name)), renderer(renderer), mappedInput(mappedInput) {}
virtual ~Activity() = default;
virtual void onEnter() { Serial.printf("[%lu] [ACT] Entering activity: %s\n", millis(), name.c_str()); }
virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); }
virtual void loop() {}
virtual bool skipLoopDelay() { return false; }
virtual bool preventAutoSleep() { return false; }
};

View File

@@ -10,8 +10,8 @@ class ActivityWithSubactivity : public Activity {
void enterNewActivity(Activity* activity);
public:
explicit ActivityWithSubactivity(std::string name, GfxRenderer& renderer, InputManager& inputManager)
: Activity(std::move(name), renderer, inputManager) {}
explicit ActivityWithSubactivity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity(std::move(name), renderer, mappedInput) {}
void loop() override;
void onExit() override;
};

View File

@@ -2,18 +2,18 @@
#include <GfxRenderer.h>
#include "config.h"
#include "fontIds.h"
#include "images/CrossLarge.h"
void BootActivity::onEnter() {
Activity::onEnter();
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD);
renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "BOOTING");
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION);
renderer.displayBuffer();

View File

@@ -3,6 +3,7 @@
class BootActivity final : public Activity {
public:
explicit BootActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity("Boot", renderer, inputManager) {}
explicit BootActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("Boot", renderer, mappedInput) {}
void onEnter() override;
};

View File

@@ -1,17 +1,29 @@
#include "SleepActivity.h"
#include <Epub.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <SD.h>
#include <vector>
#include <SDCardManager.h>
#include <Xtc.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "config.h"
#include "fontIds.h"
#include "images/CrossLarge.h"
namespace {
// Check if path has XTC extension (.xtc or .xtch)
bool isXtcFile(const std::string& path) {
if (path.length() < 4) return false;
std::string ext4 = path.substr(path.length() - 4);
if (ext4 == ".xtc") return true;
if (path.length() >= 5) {
std::string ext5 = path.substr(path.length() - 5);
if (ext5 == ".xtch") return true;
}
return false;
}
} // namespace
void SleepActivity::onEnter() {
Activity::onEnter();
renderPopup("Entering Sleep...");
@@ -28,44 +40,46 @@ void SleepActivity::onEnter() {
}
void SleepActivity::renderPopup(const char* message) const {
const int textWidth = renderer.getTextWidth(READER_FONT_ID, message);
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
constexpr int margin = 20;
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2;
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
// renderer.clearScreen();
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(READER_FONT_ID, x + margin, y + margin, message);
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
}
void SleepActivity::renderCustomSleepScreen() const {
// Check if we have a /sleep directory
auto dir = SD.open("/sleep");
auto dir = SdMan.open("/sleep");
if (dir && dir.isDirectory()) {
std::vector<std::string> files;
char name[128];
// collect all valid BMP files
for (File file = dir.openNextFile(); file; file = dir.openNextFile()) {
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
if (file.isDirectory()) {
file.close();
continue;
}
auto filename = std::string(file.name());
file.getName(name, sizeof(name));
auto filename = std::string(name);
if (filename[0] == '.') {
file.close();
continue;
}
if (filename.substr(filename.length() - 4) != ".bmp") {
Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), file.name());
Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), name);
file.close();
continue;
}
Bitmap bitmap(file);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), file.name());
Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), name);
file.close();
continue;
}
@@ -77,8 +91,8 @@ void SleepActivity::renderCustomSleepScreen() const {
// Generate a random number between 1 and numFiles
const auto randomFileIndex = random(numFiles);
const auto filename = "/sleep/" + files[randomFileIndex];
File file;
if (FsHelpers::openFileForRead("SLP", filename, file)) {
FsFile file;
if (SdMan.openFileForRead("SLP", filename, file)) {
Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
delay(100);
Bitmap bitmap(file);
@@ -94,8 +108,8 @@ void SleepActivity::renderCustomSleepScreen() const {
// Look for sleep.bmp on the root of the sd card to determine if we should
// render a custom sleep screen instead of the default.
File file;
if (FsHelpers::openFileForRead("SLP", "/sleep.bmp", file)) {
FsFile file;
if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis());
@@ -112,8 +126,8 @@ void SleepActivity::renderDefaultSleepScreen() const {
const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD);
renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
// Make sleep screen dark unless light is selected in settings
@@ -176,19 +190,41 @@ void SleepActivity::renderCoverSleepScreen() const {
return renderDefaultSleepScreen();
}
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastEpub.load()) {
Serial.println("[SLP] Failed to load last epub");
return renderDefaultSleepScreen();
std::string coverBmpPath;
// Check if the current book is XTC or EPUB
if (isXtcFile(APP_STATE.openEpubPath)) {
// Handle XTC file
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastXtc.load()) {
Serial.println("[SLP] Failed to load last XTC");
return renderDefaultSleepScreen();
}
if (!lastXtc.generateCoverBmp()) {
Serial.println("[SLP] Failed to generate XTC cover bmp");
return renderDefaultSleepScreen();
}
coverBmpPath = lastXtc.getCoverBmpPath();
} else {
// Handle EPUB file
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastEpub.load()) {
Serial.println("[SLP] Failed to load last epub");
return renderDefaultSleepScreen();
}
if (!lastEpub.generateCoverBmp()) {
Serial.println("[SLP] Failed to generate cover bmp");
return renderDefaultSleepScreen();
}
coverBmpPath = lastEpub.getCoverBmpPath();
}
if (!lastEpub.generateCoverBmp()) {
Serial.println("[SLP] Failed to generate cover bmp");
return renderDefaultSleepScreen();
}
File file;
if (FsHelpers::openFileForRead("SLP", lastEpub.getCoverBmpPath(), file)) {
FsFile file;
if (SdMan.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
renderBitmapSleepScreen(bitmap);

View File

@@ -5,8 +5,8 @@ class Bitmap;
class SleepActivity final : public Activity {
public:
explicit SleepActivity(GfxRenderer& renderer, InputManager& inputManager)
: Activity("Sleep", renderer, inputManager) {}
explicit SleepActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("Sleep", renderer, mappedInput) {}
void onEnter() override;
private:

View File

@@ -1,25 +1,56 @@
#include "HomeActivity.h"
#include <Epub.h>
#include <GfxRenderer.h>
#include <InputManager.h>
#include <SD.h>
#include <SDCardManager.h>
#include "config.h"
namespace {
constexpr int menuItemCount = 3;
}
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "fontIds.h"
void HomeActivity::taskTrampoline(void* param) {
auto* self = static_cast<HomeActivity*>(param);
self->displayTaskLoop();
}
int HomeActivity::getMenuItemCount() const { return hasContinueReading ? 4 : 3; }
void HomeActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Check if we have a book to continue reading
hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str());
if (hasContinueReading) {
// Extract filename from path for display
lastBookTitle = APP_STATE.openEpubPath;
const size_t lastSlash = lastBookTitle.find_last_of('/');
if (lastSlash != std::string::npos) {
lastBookTitle = lastBookTitle.substr(lastSlash + 1);
}
const std::string ext4 = lastBookTitle.length() >= 4 ? lastBookTitle.substr(lastBookTitle.length() - 4) : "";
const std::string ext5 = lastBookTitle.length() >= 5 ? lastBookTitle.substr(lastBookTitle.length() - 5) : "";
// If epub, try to load the metadata for title/author
if (ext5 == ".epub") {
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
epub.load(false);
if (!epub.getTitle().empty()) {
lastBookTitle = std::string(epub.getTitle());
}
if (!epub.getAuthor().empty()) {
lastBookAuthor = std::string(epub.getAuthor());
}
} else if (ext5 == ".xtch") {
lastBookTitle.resize(lastBookTitle.length() - 5);
} else if (ext4 == ".xtc") {
lastBookTitle.resize(lastBookTitle.length() - 4);
}
}
selectorIndex = 0;
// Trigger first update
@@ -47,24 +78,40 @@ void HomeActivity::onExit() {
}
void HomeActivity::loop() {
const bool prevPressed =
inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT);
const bool nextPressed =
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT);
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right);
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (selectorIndex == 0) {
onReaderOpen();
} else if (selectorIndex == 1) {
onFileTransferOpen();
} else if (selectorIndex == 2) {
onSettingsOpen();
const int menuCount = getMenuItemCount();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (hasContinueReading) {
// Menu: Continue Reading, Browse, File transfer, Settings
if (selectorIndex == 0) {
onContinueReading();
} else if (selectorIndex == 1) {
onReaderOpen();
} else if (selectorIndex == 2) {
onFileTransferOpen();
} else if (selectorIndex == 3) {
onSettingsOpen();
}
} else {
// Menu: Browse, File transfer, Settings
if (selectorIndex == 0) {
onReaderOpen();
} else if (selectorIndex == 1) {
onFileTransferOpen();
} else if (selectorIndex == 2) {
onSettingsOpen();
}
}
} else if (prevPressed) {
selectorIndex = (selectorIndex + menuItemCount - 1) % menuItemCount;
selectorIndex = (selectorIndex + menuCount - 1) % menuCount;
updateRequired = true;
} else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuItemCount;
selectorIndex = (selectorIndex + 1) % menuCount;
updateRequired = true;
}
}
@@ -86,26 +133,190 @@ void HomeActivity::render() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD);
// Draw selection
renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30);
renderer.drawText(UI_FONT_ID, 20, 60, "Read", selectorIndex != 0);
renderer.drawText(UI_FONT_ID, 20, 90, "File transfer", selectorIndex != 1);
renderer.drawText(UI_FONT_ID, 20, 120, "Settings", selectorIndex != 2);
constexpr int margin = 20;
constexpr int bottomMargin = 60;
renderer.drawRect(25, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Back")) / 2, pageHeight - 35, "Back");
// --- Top "book" card for the current title (selectorIndex == 0) ---
const int bookWidth = pageWidth / 2;
const int bookHeight = pageHeight / 2;
const int bookX = (pageWidth - bookWidth) / 2;
constexpr int bookY = 30;
const bool bookSelected = hasContinueReading && selectorIndex == 0;
renderer.drawRect(130, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Confirm")) / 2, pageHeight - 35,
"Confirm");
// Draw book card regardless, fill with message based on `hasContinueReading`
{
if (bookSelected) {
renderer.fillRect(bookX, bookY, bookWidth, bookHeight);
} else {
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
}
renderer.drawRect(245, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 245 + (105 - renderer.getTextWidth(UI_FONT_ID, "Left")) / 2, pageHeight - 35, "Left");
// Bookmark icon in the top-right corner of the card
const int bookmarkWidth = bookWidth / 8;
const int bookmarkHeight = bookHeight / 5;
const int bookmarkX = bookX + bookWidth - bookmarkWidth - 8;
constexpr int bookmarkY = bookY + 1;
renderer.drawRect(350, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 350 + (105 - renderer.getTextWidth(UI_FONT_ID, "Right")) / 2, pageHeight - 35, "Right");
// Main bookmark body (solid)
renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, !bookSelected);
// Carve out an inverted triangle notch at the bottom center to create angled points
const int notchHeight = bookmarkHeight / 2; // depth of the notch
for (int i = 0; i < notchHeight; ++i) {
const int y = bookmarkY + bookmarkHeight - 1 - i;
const int xStart = bookmarkX + i;
const int width = bookmarkWidth - 2 * i;
if (width <= 0) {
break;
}
// Draw a horizontal strip in the opposite color to "cut" the notch
renderer.fillRect(xStart, y, width, 1, bookSelected);
}
}
if (hasContinueReading) {
// Split into words (avoid stringstream to keep this light on the MCU)
std::vector<std::string> words;
words.reserve(8);
size_t pos = 0;
while (pos < lastBookTitle.size()) {
while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') {
++pos;
}
if (pos >= lastBookTitle.size()) {
break;
}
const size_t start = pos;
while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') {
++pos;
}
words.emplace_back(lastBookTitle.substr(start, pos - start));
}
std::vector<std::string> lines;
std::string currentLine;
// Extra padding inside the card so text doesn't hug the border
const int maxLineWidth = bookWidth - 40;
const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID);
for (auto& i : words) {
// If we just hit the line limit (3), stop processing words
if (lines.size() >= 3) {
// Limit to 3 lines
// Still have words left, so add ellipsis to last line
lines.back().append("...");
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
lines.back().resize(lines.back().size() - 5);
lines.back().append("...");
}
break;
}
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
while (wordWidth > maxLineWidth && i.size() > 5) {
// Word itself is too long, trim it
i.resize(i.size() - 5);
i.append("...");
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
}
int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str());
if (newLineWidth > 0) {
newLineWidth += spaceWidth;
}
newLineWidth += wordWidth;
if (newLineWidth > maxLineWidth && !currentLine.empty()) {
// New line too long, push old line
lines.push_back(currentLine);
currentLine = i;
} else {
currentLine.append(" ").append(i);
}
}
// If lower than the line limit, push remaining words
if (!currentLine.empty() && lines.size() < 3) {
lines.push_back(currentLine);
}
// Book title text
int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast<int>(lines.size());
if (!lastBookAuthor.empty()) {
totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
}
// Vertically center the title block within the card
int titleYStart = bookY + (bookHeight - totalTextHeight) / 2;
for (const auto& line : lines) {
renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected);
titleYStart += renderer.getLineHeight(UI_12_FONT_ID);
}
if (!lastBookAuthor.empty()) {
titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2;
std::string trimmedAuthor = lastBookAuthor;
// Trim author if too long
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
trimmedAuthor.resize(trimmedAuthor.size() - 5);
trimmedAuthor.append("...");
}
renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected);
}
renderer.drawCenteredText(UI_10_FONT_ID, bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2,
"Continue Reading", !bookSelected);
} else {
// No book to continue reading
const int y =
bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book");
renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below");
}
// --- Bottom menu tiles (indices 1-3) ---
const int menuTileWidth = pageWidth - 2 * margin;
constexpr int menuTileHeight = 50;
constexpr int menuSpacing = 10;
constexpr int totalMenuHeight = 3 * menuTileHeight + 2 * menuSpacing;
int menuStartY = bookY + bookHeight + 20;
// Ensure we don't collide with the bottom button legend
const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin;
if (menuStartY > maxMenuStartY) {
menuStartY = maxMenuStartY;
}
for (int i = 0; i < 3; ++i) {
constexpr const char* items[3] = {"Browse files", "File transfer", "Settings"};
const int overallIndex = i + (getMenuItemCount() - 3);
constexpr int tileX = margin;
const int tileY = menuStartY + i * (menuTileHeight + menuSpacing);
const bool selected = selectorIndex == overallIndex;
if (selected) {
renderer.fillRect(tileX, tileY, menuTileWidth, menuTileHeight);
} else {
renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight);
}
const char* label = items[i];
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label);
const int textX = tileX + (menuTileWidth - textWidth) / 2;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text
// Invert text when the tile is selected, to contrast with the filled background
renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected);
}
const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
ScreenComponents::drawBattery(renderer, 20, pageHeight - 70);
renderer.displayBuffer();
}

View File

@@ -12,6 +12,10 @@ class HomeActivity final : public Activity {
SemaphoreHandle_t renderingMutex = nullptr;
int selectorIndex = 0;
bool updateRequired = false;
bool hasContinueReading = false;
std::string lastBookTitle;
std::string lastBookAuthor;
const std::function<void()> onContinueReading;
const std::function<void()> onReaderOpen;
const std::function<void()> onSettingsOpen;
const std::function<void()> onFileTransferOpen;
@@ -19,11 +23,14 @@ class HomeActivity final : public Activity {
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
int getMenuItemCount() const;
public:
explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onReaderOpen,
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onContinueReading, const std::function<void()>& onReaderOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen)
: Activity("Home", renderer, inputManager),
: Activity("Home", renderer, mappedInput),
onContinueReading(onContinueReading),
onReaderOpen(onReaderOpen),
onSettingsOpen(onSettingsOpen),
onFileTransferOpen(onFileTransferOpen) {}

View File

@@ -3,12 +3,15 @@
#include <DNSServer.h>
#include <ESPmDNS.h>
#include <GfxRenderer.h>
#include <InputManager.h>
#include <WiFi.h>
#include <qrcode.h>
#include <cstddef>
#include "MappedInputManager.h"
#include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.h"
#include "config.h"
#include "fontIds.h"
namespace {
// AP Mode configuration
@@ -54,7 +57,7 @@ void CrossPointWebServerActivity::onEnter() {
// Launch network mode selection subactivity
Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis());
enterNewActivity(new NetworkModeSelectionActivity(
renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
[this]() { onGoBack(); } // Cancel goes back to home
));
}
@@ -138,7 +141,7 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
state = WebServerActivityState::WIFI_SELECTION;
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis());
enterNewActivity(new WifiSelectionActivity(renderer, inputManager,
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
} else {
// AP mode - start access point
@@ -171,7 +174,7 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected)
exitActivity();
state = WebServerActivityState::MODE_SELECTION;
enterNewActivity(new NetworkModeSelectionActivity(
renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
[this]() { onGoBack(); }));
}
}
@@ -302,7 +305,7 @@ void CrossPointWebServerActivity::loop() {
}
// Handle exit on Back button
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onGoBack();
return;
}
@@ -331,40 +334,69 @@ void CrossPointWebServerActivity::render() const {
} else if (state == WebServerActivityState::AP_STARTING) {
renderer.clearScreen();
const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(READER_FONT_ID, pageHeight / 2 - 20, "Starting Hotspot...", true, BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Hotspot...", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
}
}
void CrossPointWebServerActivity::renderServerRunning() const {
const auto pageHeight = renderer.getScreenHeight();
void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data) {
// Implementation of QR code calculation
// The structure to manage the QR code
QRCode qrcode;
uint8_t qrcodeBytes[qrcode_getBufferSize(4)];
Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str());
qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str());
const uint8_t px = 6; // pixels per module
for (uint8_t cy = 0; cy < qrcode.size; cy++) {
for (uint8_t cx = 0; cx < qrcode.size; cx++) {
if (qrcode_getModule(&qrcode, cx, cy)) {
// Serial.print("**");
renderer.fillRect(x + px * cx, y + px * cy, px, px, true);
} else {
// Serial.print(" ");
}
}
// Serial.print("\n");
}
}
void CrossPointWebServerActivity::renderServerRunning() const {
// Use consistent line spacing
constexpr int LINE_SPACING = 28; // Space between lines
renderer.drawCenteredText(READER_FONT_ID, 15, "File Transfer", true, BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, EpdFontFamily::BOLD);
if (isApMode) {
// AP mode display - center the content block
const int startY = 55;
int startY = 55;
renderer.drawCenteredText(UI_FONT_ID, startY, "Hotspot Mode", true, BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, startY, "Hotspot Mode", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + connectedSSID;
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str());
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network",
true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network");
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3,
"or scan QR code with your phone to connect to Wifi.");
// Show QR code for URL
std::string wifiConfig = std::string("WIFI:T:WPA;S:") + connectedSSID + ";P:" + "" + ";;";
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, wifiConfig);
startY += 6 * 29 + 3 * LINE_SPACING;
// Show primary URL (hostname)
std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/";
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, EpdFontFamily::BOLD);
// Show IP address as fallback
std::string ipUrl = "or http://" + connectedIP + "/";
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str());
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser");
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser", true, REGULAR);
// Show QR code for URL
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 6, "or scan QR code with your phone:");
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 7, hostnameUrl);
} else {
// STA mode display (original behavior)
const int startY = 65;
@@ -373,21 +405,26 @@ void CrossPointWebServerActivity::renderServerRunning() const {
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_FONT_ID, startY, ssidInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, startY, ssidInfo.c_str());
std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ipInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING, ipInfo.c_str());
// Show web server URL prominently
std::string webInfo = "http://" + connectedIP + "/";
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, EpdFontFamily::BOLD);
// Also show hostname URL
std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/";
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str());
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser", true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser");
// Show QR code for URL
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 6, webInfo);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "or scan QR code with your phone:");
}
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR);
const auto labels = mappedInput.mapLabels("« Exit", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}

View File

@@ -63,11 +63,12 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
void stopWebServer();
public:
explicit CrossPointWebServerActivity(GfxRenderer& renderer, InputManager& inputManager,
explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onGoBack)
: ActivityWithSubactivity("CrossPointWebServer", renderer, inputManager), onGoBack(onGoBack) {}
: ActivityWithSubactivity("CrossPointWebServer", renderer, mappedInput), onGoBack(onGoBack) {}
void onEnter() override;
void onExit() override;
void loop() override;
bool skipLoopDelay() override { return webServer && webServer->isRunning(); }
bool preventAutoSleep() override { return webServer && webServer->isRunning(); }
};

View File

@@ -1,9 +1,9 @@
#include "NetworkModeSelectionActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include "config.h"
#include "MappedInputManager.h"
#include "fontIds.h"
namespace {
constexpr int MENU_ITEM_COUNT = 2;
@@ -51,23 +51,23 @@ void NetworkModeSelectionActivity::onExit() {
void NetworkModeSelectionActivity::loop() {
// Handle back button - cancel
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onCancel();
return;
}
// Handle confirm button - select current option
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT;
onModeSelected(mode);
return;
}
// Handle navigation
const bool prevPressed =
inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT);
const bool nextPressed =
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT);
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right);
if (prevPressed) {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT;
@@ -97,10 +97,10 @@ void NetworkModeSelectionActivity::render() const {
const auto pageHeight = renderer.getScreenHeight();
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 10, "File Transfer", true, BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, EpdFontFamily::BOLD);
// Draw subtitle
renderer.drawCenteredText(UI_FONT_ID, 50, "How would you like to connect?", true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, 50, "How would you like to connect?");
// Draw menu items centered on screen
constexpr int itemHeight = 50; // Height for each menu item (including description)
@@ -117,12 +117,13 @@ void NetworkModeSelectionActivity::render() const {
// Draw text: black=false (white text) when selected (on black background)
// black=true (black text) when not selected (on white background)
renderer.drawText(UI_FONT_ID, 30, itemY, MENU_ITEMS[i], /*black=*/!isSelected);
renderer.drawText(UI_10_FONT_ID, 30, itemY, MENU_ITEMS[i], /*black=*/!isSelected);
renderer.drawText(SMALL_FONT_ID, 30, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected);
}
// Draw help text at bottom
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press OK to select, BACK to cancel", true, REGULAR);
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -31,10 +31,10 @@ class NetworkModeSelectionActivity final : public Activity {
void render() const;
public:
explicit NetworkModeSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
explicit NetworkModeSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(NetworkMode)>& onModeSelected,
const std::function<void()>& onCancel)
: Activity("NetworkModeSelection", renderer, inputManager), onModeSelected(onModeSelected), onCancel(onCancel) {}
: Activity("NetworkModeSelection", renderer, mappedInput), onModeSelected(onModeSelected), onCancel(onCancel) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@@ -5,9 +5,10 @@
#include <map>
#include "MappedInputManager.h"
#include "WifiCredentialStore.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "config.h"
#include "fontIds.h"
void WifiSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<WifiSelectionActivity*>(param);
@@ -150,6 +151,11 @@ void WifiSelectionActivity::processWifiScanResults() {
std::sort(networks.begin(), networks.end(),
[](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; });
// Show networks with PW first
std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) {
return a.hasSavedPassword && !b.hasSavedPassword;
});
WiFi.scanDelete();
state = WifiSelectionState::NETWORK_LIST;
selectedNetworkIndex = 0;
@@ -182,12 +188,25 @@ void WifiSelectionActivity::selectNetwork(const int index) {
if (selectedRequiresPassword) {
// Show password entry
state = WifiSelectionState::PASSWORD_ENTRY;
enterNewActivity(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password",
"", // No initial text
64, // Max password length
false // Show password by default (hard keyboard to use)
));
// Don't allow screen updates while changing activity
xSemaphoreTake(renderingMutex, portMAX_DELAY);
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, "Enter WiFi Password",
"", // No initial text
50, // Y position
64, // Max password length
false, // Show password by default (hard keyboard to use)
[this](const std::string& text) {
enteredPassword = text;
exitActivity();
},
[this] {
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
exitActivity();
}));
updateRequired = true;
xSemaphoreGive(renderingMutex);
} else {
// Connect directly for open networks
attemptConnection();
@@ -203,11 +222,6 @@ void WifiSelectionActivity::attemptConnection() {
WiFi.mode(WIFI_STA);
// Get password from keyboard if we just entered it
if (subActivity && !usedSavedPassword) {
enteredPassword = static_cast<KeyboardEntryActivity*>(subActivity.get())->getText();
}
if (selectedRequiresPassword && !enteredPassword.empty()) {
WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str());
} else {
@@ -264,6 +278,11 @@ void WifiSelectionActivity::checkConnectionStatus() {
}
void WifiSelectionActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Check scan progress
if (state == WifiSelectionState::SCANNING) {
processWifiScanResults();
@@ -276,40 +295,27 @@ void WifiSelectionActivity::loop() {
return;
}
// Handle password entry state
if (state == WifiSelectionState::PASSWORD_ENTRY && subActivity) {
const auto keyboard = static_cast<KeyboardEntryActivity*>(subActivity.get());
keyboard->handleInput();
if (keyboard->isComplete()) {
attemptConnection();
return;
}
if (keyboard->isCancelled()) {
state = WifiSelectionState::NETWORK_LIST;
exitActivity();
updateRequired = true;
return;
}
updateRequired = true;
if (state == WifiSelectionState::PASSWORD_ENTRY) {
// Reach here once password entry finished in subactivity
attemptConnection();
return;
}
// Handle save prompt state
if (state == WifiSelectionState::SAVE_PROMPT) {
if (inputManager.wasPressed(InputManager::BTN_LEFT) || inputManager.wasPressed(InputManager::BTN_UP)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (savePromptSelection > 0) {
savePromptSelection--;
updateRequired = true;
}
} else if (inputManager.wasPressed(InputManager::BTN_RIGHT) || inputManager.wasPressed(InputManager::BTN_DOWN)) {
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (savePromptSelection < 1) {
savePromptSelection++;
updateRequired = true;
}
} else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (savePromptSelection == 0) {
// User chose "Yes" - save the password
xSemaphoreTake(renderingMutex, portMAX_DELAY);
@@ -318,7 +324,7 @@ void WifiSelectionActivity::loop() {
}
// Complete - parent will start web server
onComplete(true);
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
} else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
// Skip saving, complete anyway
onComplete(true);
}
@@ -327,17 +333,19 @@ void WifiSelectionActivity::loop() {
// Handle forget prompt state (connection failed with saved credentials)
if (state == WifiSelectionState::FORGET_PROMPT) {
if (inputManager.wasPressed(InputManager::BTN_LEFT) || inputManager.wasPressed(InputManager::BTN_UP)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (forgetPromptSelection > 0) {
forgetPromptSelection--;
updateRequired = true;
}
} else if (inputManager.wasPressed(InputManager::BTN_RIGHT) || inputManager.wasPressed(InputManager::BTN_DOWN)) {
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (forgetPromptSelection < 1) {
forgetPromptSelection++;
updateRequired = true;
}
} else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (forgetPromptSelection == 0) {
// User chose "Yes" - forget the network
xSemaphoreTake(renderingMutex, portMAX_DELAY);
@@ -353,7 +361,7 @@ void WifiSelectionActivity::loop() {
// Go back to network list
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
} else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
// Skip forgetting, go back to network list
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
@@ -370,7 +378,8 @@ void WifiSelectionActivity::loop() {
// Handle connection failed state
if (state == WifiSelectionState::CONNECTION_FAILED) {
if (inputManager.wasPressed(InputManager::BTN_BACK) || inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
// If we used saved credentials, offer to forget the network
if (usedSavedPassword) {
state = WifiSelectionState::FORGET_PROMPT;
@@ -387,13 +396,13 @@ void WifiSelectionActivity::loop() {
// Handle network list state
if (state == WifiSelectionState::NETWORK_LIST) {
// Check for Back button to exit (cancel)
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onComplete(false);
return;
}
// Check for Confirm button to select network or rescan
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (!networks.empty()) {
selectNetwork(selectedNetworkIndex);
} else {
@@ -403,12 +412,14 @@ void WifiSelectionActivity::loop() {
}
// Handle UP/DOWN navigation
if (inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (selectedNetworkIndex > 0) {
selectedNetworkIndex--;
updateRequired = true;
}
} else if (inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT)) {
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (!networks.empty() && selectedNetworkIndex < static_cast<int>(networks.size()) - 1) {
selectedNetworkIndex++;
updateRequired = true;
@@ -436,6 +447,19 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi
void WifiSelectionActivity::displayTaskLoop() {
while (true) {
// If a subactivity is active, yield CPU time but don't render
if (subActivity) {
vTaskDelay(10 / portTICK_PERIOD_MS);
continue;
}
// Don't render if we're in PASSWORD_ENTRY state - we're just transitioning
// from the keyboard subactivity back to the main activity
if (state == WifiSelectionState::PASSWORD_ENTRY) {
vTaskDelay(10 / portTICK_PERIOD_MS);
continue;
}
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
@@ -456,9 +480,6 @@ void WifiSelectionActivity::render() const {
case WifiSelectionState::NETWORK_LIST:
renderNetworkList();
break;
case WifiSelectionState::PASSWORD_ENTRY:
renderPasswordEntry();
break;
case WifiSelectionState::CONNECTING:
renderConnecting();
break;
@@ -484,14 +505,14 @@ void WifiSelectionActivity::renderNetworkList() const {
const auto pageHeight = renderer.getScreenHeight();
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 10, "WiFi Networks", true, BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, "WiFi Networks", true, EpdFontFamily::BOLD);
if (networks.empty()) {
// No networks found or scan failed
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height) / 2;
renderer.drawCenteredText(UI_FONT_ID, top, "No networks found", true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press OK to scan again", true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, top, "No networks found");
renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press OK to scan again");
} else {
// Calculate how many networks we can display
constexpr int startY = 60;
@@ -512,7 +533,7 @@ void WifiSelectionActivity::renderNetworkList() const {
// Draw selection indicator
if (static_cast<int>(i) == selectedNetworkIndex) {
renderer.drawText(UI_FONT_ID, 5, networkY, ">");
renderer.drawText(UI_10_FONT_ID, 5, networkY, ">");
}
// Draw network name (truncate if too long)
@@ -520,20 +541,20 @@ void WifiSelectionActivity::renderNetworkList() const {
if (displayName.length() > 16) {
displayName.replace(13, displayName.length() - 13, "...");
}
renderer.drawText(UI_FONT_ID, 20, networkY, displayName.c_str());
renderer.drawText(UI_10_FONT_ID, 20, networkY, displayName.c_str());
// Draw signal strength indicator
std::string signalStr = getSignalStrengthIndicator(network.rssi);
renderer.drawText(UI_FONT_ID, pageWidth - 90, networkY, signalStr.c_str());
renderer.drawText(UI_10_FONT_ID, pageWidth - 90, networkY, signalStr.c_str());
// Draw saved indicator (checkmark) for networks with saved passwords
if (network.hasSavedPassword) {
renderer.drawText(UI_FONT_ID, pageWidth - 50, networkY, "+");
renderer.drawText(UI_10_FONT_ID, pageWidth - 50, networkY, "+");
}
// Draw lock icon for encrypted networks
if (network.isEncrypted) {
renderer.drawText(UI_FONT_ID, pageWidth - 30, networkY, "*");
renderer.drawText(UI_10_FONT_ID, pageWidth - 30, networkY, "*");
}
}
@@ -548,82 +569,67 @@ void WifiSelectionActivity::renderNetworkList() const {
// Show network count
char countStr[32];
snprintf(countStr, sizeof(countStr), "%zu networks found", networks.size());
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 45, countStr);
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 90, countStr);
}
// Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "OK: Connect | * = Encrypted | + = Saved");
}
void WifiSelectionActivity::renderPasswordEntry() const {
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 5, "WiFi Password", true, BOLD);
// Draw network name with good spacing from header
std::string networkInfo = "Network: " + selectedSSID;
if (networkInfo.length() > 30) {
networkInfo.replace(27, networkInfo.length() - 27, "...");
}
renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR);
// Draw keyboard
if (subActivity) {
static_cast<KeyboardEntryActivity*>(subActivity.get())->render(58);
}
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved");
const auto labels = mappedInput.mapLabels("« Back", "Connect", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
void WifiSelectionActivity::renderConnecting() const {
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height) / 2;
if (state == WifiSelectionState::SCANNING) {
renderer.drawCenteredText(UI_FONT_ID, top, "Scanning...", true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, top, "Scanning...");
} else {
renderer.drawCenteredText(READER_FONT_ID, top - 30, "Connecting...", true, BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connecting...", true, EpdFontFamily::BOLD);
std::string ssidInfo = "to " + selectedSSID;
if (ssidInfo.length() > 25) {
ssidInfo.replace(22, ssidInfo.length() - 22, "...");
}
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str());
}
}
void WifiSelectionActivity::renderConnected() const {
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 4) / 2;
renderer.drawCenteredText(READER_FONT_ID, top - 30, "Connected!", true, BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, top - 30, "Connected!", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, top + 10, ssidInfo.c_str());
const std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, ipInfo.c_str());
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue", true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue");
}
void WifiSelectionActivity::renderSavePrompt() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(READER_FONT_ID, top - 40, "Connected!", true, BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connected!", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str());
renderer.drawCenteredText(UI_FONT_ID, top + 40, "Save password for next time?", true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Save password for next time?");
// Draw Yes/No buttons
const int buttonY = top + 80;
@@ -634,46 +640,46 @@ void WifiSelectionActivity::renderSavePrompt() const {
// Draw "Yes" button
if (savePromptSelection == 0) {
renderer.drawText(UI_FONT_ID, startX, buttonY, "[Yes]");
renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Yes]");
} else {
renderer.drawText(UI_FONT_ID, startX + 4, buttonY, "Yes");
renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, "Yes");
}
// Draw "No" button
if (savePromptSelection == 1) {
renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]");
renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]");
} else {
renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No");
renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No");
}
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm", true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm");
}
void WifiSelectionActivity::renderConnectionFailed() const {
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 2) / 2;
renderer.drawCenteredText(READER_FONT_ID, top - 20, "Connection Failed", true, BOLD);
renderer.drawCenteredText(UI_FONT_ID, top + 20, connectionError.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue", true, REGULAR);
renderer.drawCenteredText(UI_12_FONT_ID, top - 20, "Connection Failed", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, top + 20, connectionError.c_str());
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue");
}
void WifiSelectionActivity::renderForgetPrompt() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(READER_FONT_ID, top - 40, "Forget Network?", true, BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network?", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str());
renderer.drawCenteredText(UI_FONT_ID, top + 40, "Remove saved password?", true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Remove saved password?");
// Draw Yes/No buttons
const int buttonY = top + 80;
@@ -684,17 +690,17 @@ void WifiSelectionActivity::renderForgetPrompt() const {
// Draw "Yes" button
if (forgetPromptSelection == 0) {
renderer.drawText(UI_FONT_ID, startX, buttonY, "[Yes]");
renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Yes]");
} else {
renderer.drawText(UI_FONT_ID, startX + 4, buttonY, "Yes");
renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, "Yes");
}
// Draw "No" button
if (forgetPromptSelection == 1) {
renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]");
renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]");
} else {
renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No");
renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No");
}
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm", true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm");
}

View File

@@ -92,9 +92,9 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
std::string getSignalStrengthIndicator(int32_t rssi) const;
public:
explicit WifiSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
explicit WifiSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(bool connected)>& onComplete)
: ActivityWithSubactivity("WifiSelection", renderer, inputManager), onComplete(onComplete) {}
: ActivityWithSubactivity("WifiSelection", renderer, mappedInput), onComplete(onComplete) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@@ -3,22 +3,22 @@
#include <Epub/Page.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <InputManager.h>
#include <SDCardManager.h>
#include "Battery.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "config.h"
#include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "fontIds.h"
namespace {
constexpr int pagesPerRefresh = 15;
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
constexpr unsigned long skipChapterMs = 700;
constexpr float lineCompression = 0.95f;
constexpr int marginTop = 8;
constexpr int marginRight = 10;
constexpr int marginBottom = 22;
constexpr int marginLeft = 10;
constexpr unsigned long goHomeMs = 1000;
constexpr int topPadding = 5;
constexpr int horizontalPadding = 5;
constexpr int statusBarMargin = 19;
} // namespace
void EpubReaderActivity::taskTrampoline(void* param) {
@@ -33,12 +33,30 @@ void EpubReaderActivity::onEnter() {
return;
}
// Configure screen orientation based on settings
switch (SETTINGS.orientation) {
case CrossPointSettings::ORIENTATION::PORTRAIT:
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
break;
case CrossPointSettings::ORIENTATION::INVERTED:
renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise);
break;
default:
break;
}
renderingMutex = xSemaphoreCreateMutex();
epub->setupCacheDir();
File f;
if (FsHelpers::openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
FsFile f;
if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
if (f.read(data, 4) == 4) {
currentSpineIndex = data[0] + (data[1] << 8);
@@ -47,6 +65,16 @@ void EpubReaderActivity::onEnter() {
}
f.close();
}
// We may want a better condition to detect if we are opening for the first time.
// This will trigger if the book is re-opened at Chapter 0.
if (currentSpineIndex == 0) {
int textSpineIndex = epub->getSpineIndexForTextReference();
if (textSpineIndex != 0) {
currentSpineIndex = textSpineIndex;
Serial.printf("[%lu] [ERS] Opened for first time, navigating to text reference at index %d\n", millis(),
textSpineIndex);
}
}
// Save current epub as last opened epub
APP_STATE.openEpubPath = epub->getPath();
@@ -66,6 +94,9 @@ void EpubReaderActivity::onEnter() {
void EpubReaderActivity::onExit() {
ActivityWithSubactivity::onExit();
// Reset orientation back to portrait for the rest of the UI
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
@@ -86,12 +117,12 @@ void EpubReaderActivity::loop() {
}
// Enter chapter selection activity
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Don't start activity transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new EpubReaderChapterSelectionActivity(
this->renderer, this->inputManager, epub, currentSpineIndex,
this->renderer, this->mappedInput, epub, currentSpineIndex,
[this] {
exitActivity();
updateRequired = true;
@@ -108,15 +139,22 @@ void EpubReaderActivity::loop() {
xSemaphoreGive(renderingMutex);
}
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
// Long press BACK (1s+) goes directly to home
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
onGoHome();
return;
}
// Short press BACK goes to file selection
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
onGoBack();
return;
}
const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
const bool nextReleased =
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT);
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
if (!prevReleased && !nextReleased) {
return;
@@ -130,7 +168,7 @@ void EpubReaderActivity::loop() {
return;
}
const bool skipChapter = inputManager.getHeldTime() > skipChapterMs;
const bool skipChapter = mappedInput.getHeldTime() > skipChapterMs;
if (skipChapter) {
// We don't want to delete the section mid-render, so grab the semaphore
@@ -206,36 +244,76 @@ void EpubReaderActivity::renderScreen() {
// Show end of book screen
if (currentSpineIndex == epub->getSpineItemsCount()) {
renderer.clearScreen();
renderer.drawCenteredText(READER_FONT_ID, 300, "End of book", true, BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 300, "End of book", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return;
}
// Apply screen viewable areas and additional padding
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
&orientedMarginLeft);
orientedMarginTop += topPadding;
orientedMarginLeft += horizontalPadding;
orientedMarginRight += horizontalPadding;
orientedMarginBottom += statusBarMargin;
if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft,
SETTINGS.extraParagraphSpacing, SETTINGS.hyphenationEnabled)) {
const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
// Progress bar dimensions
constexpr int barWidth = 200;
constexpr int barHeight = 10;
constexpr int boxMargin = 20;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing...");
const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
const int boxWidthNoBar = textWidth + boxMargin * 2;
const int boxHeightWithBar = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3;
const int boxHeightNoBar = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
const int boxXWithBar = (renderer.getScreenWidth() - boxWidthWithBar) / 2;
const int boxXNoBar = (renderer.getScreenWidth() - boxWidthNoBar) / 2;
constexpr int boxY = 50;
const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2;
const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
// Always show "Indexing..." text first
{
const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing...");
constexpr int margin = 20;
const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 50;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2;
renderer.fillRect(x, y, w, h, false);
renderer.drawText(READER_FONT_ID, x + margin, y + margin, "Indexing...");
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false);
renderer.drawText(UI_12_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10);
renderer.displayBuffer();
pagesUntilFullRefresh = 0;
}
section->setupCacheDir();
if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom,
marginLeft, SETTINGS.extraParagraphSpacing, SETTINGS.hyphenationEnabled)) {
// Setup callback - only called for chapters >= 50KB, redraws with progress bar
auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] {
renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false);
renderer.drawText(UI_12_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10);
renderer.drawRect(barX, barY, barWidth, barHeight);
renderer.displayBuffer();
};
// Progress callback to update progress bar
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
const int fillWidth = (barWidth - 2) * progress / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
};
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, progressSetup, progressCallback, SETTINGS.hyphenationEnabled)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset();
return;
@@ -255,22 +333,22 @@ void EpubReaderActivity::renderScreen() {
if (section->pageCount == 0) {
Serial.printf("[%lu] [ERS] No pages to render\n", millis());
renderer.drawCenteredText(READER_FONT_ID, 300, "Empty chapter", true, BOLD);
renderStatusBar();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty chapter", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer();
return;
}
if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount);
renderer.drawCenteredText(READER_FONT_ID, 300, "Out of bounds", true, BOLD);
renderStatusBar();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer();
return;
}
{
auto p = section->loadPageFromSD();
auto p = section->loadPageFromSectionFile();
if (!p) {
Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis());
section->clearCache();
@@ -278,12 +356,12 @@ void EpubReaderActivity::renderScreen() {
return renderScreen();
}
const auto start = millis();
renderContents(std::move(p));
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
}
File f;
if (FsHelpers::openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
FsFile f;
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF;
@@ -294,12 +372,14 @@ void EpubReaderActivity::renderScreen() {
}
}
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
page->render(renderer, READER_FONT_ID);
renderStatusBar();
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh;
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
@@ -313,13 +393,13 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
{
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
page->render(renderer, READER_FONT_ID);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderer.copyGrayscaleLsbBuffers();
// Render and copy to MSB buffer
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
page->render(renderer, READER_FONT_ID);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderer.copyGrayscaleMsbBuffers();
// display grayscale part
@@ -331,72 +411,60 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
renderer.restoreBwBuffer();
}
void EpubReaderActivity::renderStatusBar() const {
constexpr auto textY = 776;
void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) const {
// determine visible status bar elements
const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
// Calculate progress in book
const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg);
// Position status bar near the bottom of the logical screen, regardless of orientation
const auto screenHeight = renderer.getScreenHeight();
const auto textY = screenHeight - orientedMarginBottom - 4;
int progressTextWidth = 0;
// Right aligned text for progress counter
const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) +
" " + std::to_string(bookProgress) + "%";
const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY,
progress.c_str());
if (showProgress) {
// Calculate progress in book
const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg);
// Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%";
const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 10;
constexpr int x = marginLeft;
constexpr int y = 783;
// Top line
renderer.drawLine(x, y, x + batteryWidth - 4, y);
// Bottom line
renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
// Left line
renderer.drawLine(x, y, x, y + batteryHeight - 1);
// Battery end
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow
// Right aligned text for progress counter
const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) +
" " + std::to_string(bookProgress) + "%";
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY,
progress.c_str());
}
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
// Centered chatper title text
// Page width minus existing content with 30px padding on each side
const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft;
const int titleMarginRight = progressTextWidth + 30 + marginRight;
const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight;
const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
if (showBattery) {
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY);
}
std::string title;
int titleWidth;
if (tocIndex == -1) {
title = "Unnamed";
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed");
} else {
const auto tocItem = epub->getTocItem(tocIndex);
title = tocItem.title;
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
while (titleWidth > availableTextWidth && title.length() > 11) {
title.replace(title.length() - 8, 8, "...");
if (showChapterTitle) {
// Centered chatper title text
// Page width minus existing content with 30px padding on each side
const int titleMarginLeft = 50 + 30 + orientedMarginLeft; // 50px for battery
const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight;
const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight;
const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
std::string title;
int titleWidth;
if (tocIndex == -1) {
title = "Unnamed";
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed");
} else {
const auto tocItem = epub->getTocItem(tocIndex);
title = tocItem.title;
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
while (titleWidth > availableTextWidth && title.length() > 11) {
title.replace(title.length() - 8, 8, "...");
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
}
}
}
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str());
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str());
}
}

View File

@@ -17,17 +17,22 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
int pagesUntilFullRefresh = 0;
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
void renderContents(std::unique_ptr<Page> p);
void renderStatusBar() const;
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
public:
explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack)
: ActivityWithSubactivity("EpubReader", renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {}
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
: ActivityWithSubactivity("EpubReader", renderer, mappedInput),
epub(std::move(epub)),
onGoBack(onGoBack),
onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@@ -1,16 +1,31 @@
#include "EpubReaderChapterSelectionActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include <SD.h>
#include "config.h"
#include "MappedInputManager.h"
#include "fontIds.h"
namespace {
constexpr int PAGE_ITEMS = 24;
// Time threshold for treating a long press as a page-up/page-down
constexpr int SKIP_PAGE_MS = 700;
} // namespace
int EpubReaderChapterSelectionActivity::getPageItems() const {
// Layout constants used in renderScreen
constexpr int startY = 60;
constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight();
const int availableHeight = screenHeight - startY;
int items = availableHeight / lineHeight;
// Ensure we always have at least one item per page to avoid division by zero
if (items < 1) {
items = 1;
}
return items;
}
void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderChapterSelectionActivity*>(param);
self->displayTaskLoop();
@@ -24,7 +39,10 @@ void EpubReaderChapterSelectionActivity::onEnter() {
}
renderingMutex = xSemaphoreCreateMutex();
selectorIndex = currentSpineIndex;
selectorIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
if (selectorIndex == -1) {
selectorIndex = 0;
}
// Trigger first update
updateRequired = true;
@@ -50,30 +68,36 @@ void EpubReaderChapterSelectionActivity::onExit() {
}
void EpubReaderChapterSelectionActivity::loop() {
const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
const bool nextReleased =
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT);
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS;
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems();
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
onSelectSpineIndex(selectorIndex);
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto newSpineIndex = epub->getSpineIndexForTocIndex(selectorIndex);
if (newSpineIndex == -1) {
onGoBack();
} else {
onSelectSpineIndex(newSpineIndex);
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
} else if (prevReleased) {
if (skipPage) {
selectorIndex =
((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + epub->getSpineItemsCount()) % epub->getSpineItemsCount();
((selectorIndex / pageItems - 1) * pageItems + epub->getTocItemsCount()) % epub->getTocItemsCount();
} else {
selectorIndex = (selectorIndex + epub->getSpineItemsCount() - 1) % epub->getSpineItemsCount();
selectorIndex = (selectorIndex + epub->getTocItemsCount() - 1) % epub->getTocItemsCount();
}
updateRequired = true;
} else if (nextReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % epub->getSpineItemsCount();
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % epub->getTocItemsCount();
} else {
selectorIndex = (selectorIndex + 1) % epub->getSpineItemsCount();
selectorIndex = (selectorIndex + 1) % epub->getTocItemsCount();
}
updateRequired = true;
}
@@ -95,19 +119,19 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
renderer.drawCenteredText(READER_FONT_ID, 10, "Select Chapter", true, BOLD);
const int pageItems = getPageItems();
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30);
for (int i = pageStartIndex; i < epub->getSpineItemsCount() && i < pageStartIndex + PAGE_ITEMS; i++) {
const int tocIndex = epub->getTocIndexForSpineIndex(i);
if (tocIndex == -1) {
renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, "Unnamed", i != selectorIndex);
} else {
auto item = epub->getTocItem(tocIndex);
renderer.drawText(UI_FONT_ID, 20 + (item.level - 1) * 15, 60 + (i % PAGE_ITEMS) * 30, item.title.c_str(),
i != selectorIndex);
}
const std::string title =
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, title.c_str(), true, EpdFontFamily::BOLD);
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
for (int tocIndex = pageStartIndex; tocIndex < epub->getTocItemsCount() && tocIndex < pageStartIndex + pageItems;
tocIndex++) {
auto item = epub->getTocItem(tocIndex);
renderer.drawText(UI_10_FONT_ID, 20 + (item.level - 1) * 15, 60 + (tocIndex % pageItems) * 30, item.title.c_str(),
tocIndex != selectorIndex);
}
renderer.displayBuffer();

View File

@@ -18,16 +18,20 @@ class EpubReaderChapterSelectionActivity final : public Activity {
const std::function<void()> onGoBack;
const std::function<void(int newSpineIndex)> onSelectSpineIndex;
// Number of items that fit on a page, derived from logical screen height.
// This adapts automatically when switching between portrait and landscape.
int getPageItems() const;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
public:
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::shared_ptr<Epub>& epub, const int currentSpineIndex,
const std::function<void()>& onGoBack,
const std::function<void(int newSpineIndex)>& onSelectSpineIndex)
: Activity("EpubReaderChapterSelection", renderer, inputManager),
: Activity("EpubReaderChapterSelection", renderer, mappedInput),
epub(epub),
currentSpineIndex(currentSpineIndex),
onGoBack(onGoBack),

View File

@@ -1,14 +1,15 @@
#include "FileSelectionActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include <SD.h>
#include <SDCardManager.h>
#include "config.h"
#include "MappedInputManager.h"
#include "fontIds.h"
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
void sortFileList(std::vector<std::string>& strs) {
@@ -29,18 +30,32 @@ void FileSelectionActivity::taskTrampoline(void* param) {
void FileSelectionActivity::loadFiles() {
files.clear();
selectorIndex = 0;
auto root = SD.open(basepath.c_str());
for (File file = root.openNextFile(); file; file = root.openNextFile()) {
auto filename = std::string(file.name());
if (filename[0] == '.') {
auto root = SdMan.open(basepath.c_str());
if (!root || !root.isDirectory()) {
if (root) root.close();
return;
}
root.rewindDirectory();
char name[128];
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
file.getName(name, sizeof(name));
if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) {
file.close();
continue;
}
if (file.isDirectory()) {
files.emplace_back(filename + "/");
} else if (filename.substr(filename.length() - 5) == ".epub") {
files.emplace_back(filename);
files.emplace_back(std::string(name) + "/");
} else {
auto filename = std::string(name);
std::string ext4 = filename.length() >= 4 ? filename.substr(filename.length() - 4) : "";
std::string ext5 = filename.length() >= 5 ? filename.substr(filename.length() - 5) : "";
if (ext5 == ".epub" || ext5 == ".xtch" || ext4 == ".xtc") {
files.emplace_back(filename);
}
}
file.close();
}
@@ -53,7 +68,7 @@ void FileSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
basepath = "/";
// basepath is set via constructor parameter (defaults to "/" if not specified)
loadFiles();
selectorIndex = 0;
@@ -83,14 +98,24 @@ void FileSelectionActivity::onExit() {
}
void FileSelectionActivity::loop() {
const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
const bool nextReleased =
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT);
// Long press BACK (1s+) goes to root folder
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS) {
if (basepath != "/") {
basepath = "/";
loadFiles();
updateRequired = true;
}
return;
}
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS;
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (files.empty()) {
return;
}
@@ -103,15 +128,17 @@ void FileSelectionActivity::loop() {
} else {
onSelect(basepath + files[selectorIndex]);
}
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (basepath != "/") {
basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
if (basepath.empty()) basepath = "/";
loadFiles();
updateRequired = true;
} else {
// At root level, go back home
onGoHome();
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
// Short press: go up one directory, or go home if at root
if (mappedInput.getHeldTime() < GO_HOME_MS) {
if (basepath != "/") {
basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
if (basepath.empty()) basepath = "/";
loadFiles();
updateRequired = true;
} else {
onGoHome();
}
}
} else if (prevReleased) {
if (skipPage) {
@@ -145,28 +172,24 @@ void FileSelectionActivity::displayTaskLoop() {
void FileSelectionActivity::render() const {
renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth();
renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD);
const auto pageWidth = renderer.getScreenWidth();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Books", true, EpdFontFamily::BOLD);
// Help text
renderer.drawText(SMALL_FONT_ID, 20, GfxRenderer::getScreenHeight() - 30, "Press BACK for Home");
const auto labels = mappedInput.mapLabels("« Home", "Open", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
if (files.empty()) {
renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found");
renderer.drawText(UI_10_FONT_ID, 20, 60, "No books found");
renderer.displayBuffer();
return;
}
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30);
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30);
for (int i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) {
auto item = files[i];
int itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str());
while (itemWidth > renderer.getScreenWidth() - 40 && item.length() > 8) {
item.replace(item.length() - 5, 5, "...");
itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str());
}
renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex);
auto item = renderer.truncatedText(UI_10_FONT_ID, files[i].c_str(), renderer.getScreenWidth() - 40);
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex);
}
renderer.displayBuffer();

View File

@@ -25,10 +25,13 @@ class FileSelectionActivity final : public Activity {
void loadFiles();
public:
explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
explicit FileSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(const std::string&)>& onSelect,
const std::function<void()>& onGoHome)
: Activity("FileSelection", renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {}
const std::function<void()>& onGoHome, std::string initialPath = "/")
: Activity("FileSelection", renderer, mappedInput),
basepath(initialPath.empty() ? "/" : std::move(initialPath)),
onSelect(onSelect),
onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@@ -1,14 +1,33 @@
#include "ReaderActivity.h"
#include <SD.h>
#include "Epub.h"
#include "EpubReaderActivity.h"
#include "FileSelectionActivity.h"
#include "Xtc.h"
#include "XtcReaderActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
const auto lastSlash = filePath.find_last_of('/');
if (lastSlash == std::string::npos || lastSlash == 0) {
return "/";
}
return filePath.substr(0, lastSlash);
}
bool ReaderActivity::isXtcFile(const std::string& path) {
if (path.length() < 4) return false;
std::string ext4 = path.substr(path.length() - 4);
if (ext4 == ".xtc") return true;
if (path.length() >= 5) {
std::string ext5 = path.substr(path.length() - 5);
if (ext5 == ".xtch") return true;
}
return false;
}
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
if (!SD.exists(path.c_str())) {
if (!SdMan.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
return nullptr;
}
@@ -22,46 +41,102 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
return nullptr;
}
void ReaderActivity::onSelectEpubFile(const std::string& path) {
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading..."));
std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
if (!SdMan.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
return nullptr;
}
auto epub = loadEpub(path);
if (epub) {
onGoToEpubReader(std::move(epub));
auto xtc = std::unique_ptr<Xtc>(new Xtc(path, "/.crosspoint"));
if (xtc->load()) {
return xtc;
}
Serial.printf("[%lu] [ ] Failed to load XTC\n", millis());
return nullptr;
}
void ReaderActivity::onSelectBookFile(const std::string& path) {
currentBookPath = path; // Track current book path
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Loading..."));
if (isXtcFile(path)) {
// Load XTC file
auto xtc = loadXtc(path);
if (xtc) {
onGoToXtcReader(std::move(xtc));
} else {
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load XTC",
EpdFontFamily::REGULAR, EInkDisplay::HALF_REFRESH));
delay(2000);
onGoToFileSelection();
}
} else {
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load epub", REGULAR,
EInkDisplay::HALF_REFRESH));
delay(2000);
onGoToFileSelection();
// Load EPUB file
auto epub = loadEpub(path);
if (epub) {
onGoToEpubReader(std::move(epub));
} else {
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load epub",
EpdFontFamily::REGULAR, EInkDisplay::HALF_REFRESH));
delay(2000);
onGoToFileSelection();
}
}
}
void ReaderActivity::onGoToFileSelection() {
void ReaderActivity::onGoToFileSelection(const std::string& fromBookPath) {
exitActivity();
// If coming from a book, start in that book's folder; otherwise start from root
const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath);
enterNewActivity(new FileSelectionActivity(
renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack));
renderer, mappedInput, [this](const std::string& path) { onSelectBookFile(path); }, onGoBack, initialPath));
}
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
const auto epubPath = epub->getPath();
currentBookPath = epubPath;
exitActivity();
enterNewActivity(new EpubReaderActivity(renderer, inputManager, std::move(epub), [this] { onGoToFileSelection(); }));
enterNewActivity(new EpubReaderActivity(
renderer, mappedInput, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); },
[this] { onGoBack(); }));
}
void ReaderActivity::onGoToXtcReader(std::unique_ptr<Xtc> xtc) {
const auto xtcPath = xtc->getPath();
currentBookPath = xtcPath;
exitActivity();
enterNewActivity(new XtcReaderActivity(
renderer, mappedInput, std::move(xtc), [this, xtcPath] { onGoToFileSelection(xtcPath); },
[this] { onGoBack(); }));
}
void ReaderActivity::onEnter() {
ActivityWithSubactivity::onEnter();
if (initialEpubPath.empty()) {
onGoToFileSelection();
if (initialBookPath.empty()) {
onGoToFileSelection(); // Start from root when entering via Browse
return;
}
auto epub = loadEpub(initialEpubPath);
if (!epub) {
onGoBack();
return;
}
currentBookPath = initialBookPath;
onGoToEpubReader(std::move(epub));
if (isXtcFile(initialBookPath)) {
auto xtc = loadXtc(initialBookPath);
if (!xtc) {
onGoBack();
return;
}
onGoToXtcReader(std::move(xtc));
} else {
auto epub = loadEpub(initialBookPath);
if (!epub) {
onGoBack();
return;
}
onGoToEpubReader(std::move(epub));
}
}

View File

@@ -4,21 +4,27 @@
#include "../ActivityWithSubactivity.h"
class Epub;
class Xtc;
class ReaderActivity final : public ActivityWithSubactivity {
std::string initialEpubPath;
std::string initialBookPath;
std::string currentBookPath; // Track current book path for navigation
const std::function<void()> onGoBack;
static std::unique_ptr<Epub> loadEpub(const std::string& path);
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
static bool isXtcFile(const std::string& path);
void onSelectEpubFile(const std::string& path);
void onGoToFileSelection();
static std::string extractFolderPath(const std::string& filePath);
void onSelectBookFile(const std::string& path);
void onGoToFileSelection(const std::string& fromBookPath = "");
void onGoToEpubReader(std::unique_ptr<Epub> epub);
void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
public:
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath,
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
const std::function<void()>& onGoBack)
: ActivityWithSubactivity("Reader", renderer, inputManager),
initialEpubPath(std::move(initialEpubPath)),
: ActivityWithSubactivity("Reader", renderer, mappedInput),
initialBookPath(std::move(initialBookPath)),
onGoBack(onGoBack) {}
void onEnter() override;
};

View File

@@ -0,0 +1,387 @@
/**
* XtcReaderActivity.cpp
*
* XTC ebook reader activity implementation
* Displays pre-rendered XTC pages on e-ink display
*/
#include "XtcReaderActivity.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "XtcReaderChapterSelectionActivity.h"
#include "fontIds.h"
namespace {
constexpr unsigned long skipPageMs = 700;
constexpr unsigned long goHomeMs = 1000;
} // namespace
void XtcReaderActivity::taskTrampoline(void* param) {
auto* self = static_cast<XtcReaderActivity*>(param);
self->displayTaskLoop();
}
void XtcReaderActivity::onEnter() {
ActivityWithSubactivity::onEnter();
if (!xtc) {
return;
}
renderingMutex = xSemaphoreCreateMutex();
xtc->setupCacheDir();
// Load saved progress
loadProgress();
// Save current XTC as last opened book
APP_STATE.openEpubPath = xtc->getPath();
APP_STATE.saveToFile();
// Trigger first update
updateRequired = true;
xTaskCreate(&XtcReaderActivity::taskTrampoline, "XtcReaderActivityTask",
4096, // Stack size (smaller than EPUB since no parsing needed)
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void XtcReaderActivity::onExit() {
ActivityWithSubactivity::onExit();
// Wait until not rendering to delete task
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
xtc.reset();
}
void XtcReaderActivity::loop() {
// Pass input responsibility to sub activity if exists
if (subActivity) {
subActivity->loop();
return;
}
// Enter chapter selection activity
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new XtcReaderChapterSelectionActivity(
this->renderer, this->mappedInput, xtc, currentPage,
[this] {
exitActivity();
updateRequired = true;
},
[this](const uint32_t newPage) {
currentPage = newPage;
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
}
}
// Long press BACK (1s+) goes directly to home
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
onGoHome();
return;
}
// Short press BACK goes to file selection
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
onGoBack();
return;
}
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
if (!prevReleased && !nextReleased) {
return;
}
// Handle end of book
if (currentPage >= xtc->getPageCount()) {
currentPage = xtc->getPageCount() - 1;
updateRequired = true;
return;
}
const bool skipPages = mappedInput.getHeldTime() > skipPageMs;
const int skipAmount = skipPages ? 10 : 1;
if (prevReleased) {
if (currentPage >= static_cast<uint32_t>(skipAmount)) {
currentPage -= skipAmount;
} else {
currentPage = 0;
}
updateRequired = true;
} else if (nextReleased) {
currentPage += skipAmount;
if (currentPage >= xtc->getPageCount()) {
currentPage = xtc->getPageCount(); // Allow showing "End of book"
}
updateRequired = true;
}
}
void XtcReaderActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void XtcReaderActivity::renderScreen() {
if (!xtc) {
return;
}
// Bounds check
if (currentPage >= xtc->getPageCount()) {
// Show end of book screen
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "End of book", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return;
}
renderPage();
saveProgress();
}
void XtcReaderActivity::renderPage() {
const uint16_t pageWidth = xtc->getPageWidth();
const uint16_t pageHeight = xtc->getPageHeight();
const uint8_t bitDepth = xtc->getBitDepth();
// Calculate buffer size for one page
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
// XTH (2-bit): Two bit planes, column-major, ((width * height + 7) / 8) * 2 bytes
size_t pageBufferSize;
if (bitDepth == 2) {
pageBufferSize = ((static_cast<size_t>(pageWidth) * pageHeight + 7) / 8) * 2;
} else {
pageBufferSize = ((pageWidth + 7) / 8) * pageHeight;
}
// Allocate page buffer
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize));
if (!pageBuffer) {
Serial.printf("[%lu] [XTR] Failed to allocate page buffer (%lu bytes)\n", millis(), pageBufferSize);
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Memory error", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return;
}
// Load page data
size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize);
if (bytesRead == 0) {
Serial.printf("[%lu] [XTR] Failed to load page %lu\n", millis(), currentPage);
free(pageBuffer);
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Page load error", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return;
}
// Clear screen first
renderer.clearScreen();
// Copy page bitmap using GfxRenderer's drawPixel
// XTC/XTCH pages are pre-rendered with status bar included, so render full page
const uint16_t maxSrcY = pageHeight;
if (bitDepth == 2) {
// XTH 2-bit mode: Two bit planes, column-major order
// - Columns scanned right to left (x = width-1 down to 0)
// - 8 vertical pixels per byte (MSB = topmost pixel in group)
// - First plane: Bit1, Second plane: Bit2
// - Pixel value = (bit1 << 1) | bit2
// - Grayscale: 0=White, 1=Dark Grey, 2=Light Grey, 3=Black
const size_t planeSize = (static_cast<size_t>(pageWidth) * pageHeight + 7) / 8;
const uint8_t* plane1 = pageBuffer; // Bit1 plane
const uint8_t* plane2 = pageBuffer + planeSize; // Bit2 plane
const size_t colBytes = (pageHeight + 7) / 8; // Bytes per column (100 for 800 height)
// Lambda to get pixel value at (x, y)
auto getPixelValue = [&](uint16_t x, uint16_t y) -> uint8_t {
const size_t colIndex = pageWidth - 1 - x;
const size_t byteInCol = y / 8;
const size_t bitInByte = 7 - (y % 8);
const size_t byteOffset = colIndex * colBytes + byteInCol;
const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1;
const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1;
return (bit1 << 1) | bit2;
};
// Optimized grayscale rendering without storeBwBuffer (saves 48KB peak memory)
// Flow: BW display → LSB/MSB passes → grayscale display → re-render BW for next frame
// Count pixel distribution for debugging
uint32_t pixelCounts[4] = {0, 0, 0, 0};
for (uint16_t y = 0; y < pageHeight; y++) {
for (uint16_t x = 0; x < pageWidth; x++) {
pixelCounts[getPixelValue(x, y)]++;
}
}
Serial.printf("[%lu] [XTR] Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu\n", millis(),
pixelCounts[0], pixelCounts[1], pixelCounts[2], pixelCounts[3]);
// Pass 1: BW buffer - draw all non-white pixels as black
for (uint16_t y = 0; y < pageHeight; y++) {
for (uint16_t x = 0; x < pageWidth; x++) {
if (getPixelValue(x, y) >= 1) {
renderer.drawPixel(x, y, true);
}
}
}
// Display BW with conditional refresh based on pagesUntilFullRefresh
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
}
// Pass 2: LSB buffer - mark DARK gray only (XTH value 1)
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
renderer.clearScreen(0x00);
for (uint16_t y = 0; y < pageHeight; y++) {
for (uint16_t x = 0; x < pageWidth; x++) {
if (getPixelValue(x, y) == 1) { // Dark grey only
renderer.drawPixel(x, y, false);
}
}
}
renderer.copyGrayscaleLsbBuffers();
// Pass 3: MSB buffer - mark LIGHT AND DARK gray (XTH value 1 or 2)
// In LUT: 0 bit = apply gray effect, 1 bit = untouched
renderer.clearScreen(0x00);
for (uint16_t y = 0; y < pageHeight; y++) {
for (uint16_t x = 0; x < pageWidth; x++) {
const uint8_t pv = getPixelValue(x, y);
if (pv == 1 || pv == 2) { // Dark grey or Light grey
renderer.drawPixel(x, y, false);
}
}
}
renderer.copyGrayscaleMsbBuffers();
// Display grayscale overlay
renderer.displayGrayBuffer();
// Pass 4: Re-render BW to framebuffer (restore for next frame, instead of restoreBwBuffer)
renderer.clearScreen();
for (uint16_t y = 0; y < pageHeight; y++) {
for (uint16_t x = 0; x < pageWidth; x++) {
if (getPixelValue(x, y) >= 1) {
renderer.drawPixel(x, y, true);
}
}
}
// Cleanup grayscale buffers with current frame buffer
renderer.cleanupGrayscaleWithFrameBuffer();
free(pageBuffer);
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (2-bit grayscale)\n", millis(), currentPage + 1,
xtc->getPageCount());
return;
} else {
// 1-bit mode: 8 pixels per byte, MSB first
const size_t srcRowBytes = (pageWidth + 7) / 8; // 60 bytes for 480 width
for (uint16_t srcY = 0; srcY < maxSrcY; srcY++) {
const size_t srcRowStart = srcY * srcRowBytes;
for (uint16_t srcX = 0; srcX < pageWidth; srcX++) {
// Read source pixel (MSB first, bit 7 = leftmost pixel)
const size_t srcByte = srcRowStart + srcX / 8;
const size_t srcBit = 7 - (srcX % 8);
const bool isBlack = !((pageBuffer[srcByte] >> srcBit) & 1); // XTC: 0 = black, 1 = white
if (isBlack) {
renderer.drawPixel(srcX, srcY, true);
}
}
}
}
// White pixels are already cleared by clearScreen()
free(pageBuffer);
// XTC pages already have status bar pre-rendered, no need to add our own
// Display with appropriate refresh
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
}
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (%u-bit)\n", millis(), currentPage + 1, xtc->getPageCount(),
bitDepth);
}
void XtcReaderActivity::saveProgress() const {
FsFile f;
if (SdMan.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
data[0] = currentPage & 0xFF;
data[1] = (currentPage >> 8) & 0xFF;
data[2] = (currentPage >> 16) & 0xFF;
data[3] = (currentPage >> 24) & 0xFF;
f.write(data, 4);
f.close();
}
}
void XtcReaderActivity::loadProgress() {
FsFile f;
if (SdMan.openFileForRead("XTR", xtc->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
if (f.read(data, 4) == 4) {
currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
Serial.printf("[%lu] [XTR] Loaded progress: page %lu\n", millis(), currentPage);
// Validate page number
if (currentPage >= xtc->getPageCount()) {
currentPage = 0;
}
}
f.close();
}
}

View File

@@ -0,0 +1,44 @@
/**
* XtcReaderActivity.h
*
* XTC ebook reader activity for CrossPoint Reader
* Displays pre-rendered XTC pages on e-ink display
*/
#pragma once
#include <Xtc.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "activities/ActivityWithSubactivity.h"
class XtcReaderActivity final : public ActivityWithSubactivity {
std::shared_ptr<Xtc> xtc;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
uint32_t currentPage = 0;
int pagesUntilFullRefresh = 0;
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
void renderPage();
void saveProgress() const;
void loadProgress();
public:
explicit XtcReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Xtc> xtc,
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
: ActivityWithSubactivity("XtcReader", renderer, mappedInput),
xtc(std::move(xtc)),
onGoBack(onGoBack),
onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@@ -0,0 +1,151 @@
#include "XtcReaderChapterSelectionActivity.h"
#include <GfxRenderer.h>
#include "MappedInputManager.h"
#include "fontIds.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
} // namespace
int XtcReaderChapterSelectionActivity::getPageItems() const {
constexpr int startY = 60;
constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight();
const int availableHeight = screenHeight - startY;
int items = availableHeight / lineHeight;
if (items < 1) {
items = 1;
}
return items;
}
int XtcReaderChapterSelectionActivity::findChapterIndexForPage(uint32_t page) const {
if (!xtc) {
return 0;
}
const auto& chapters = xtc->getChapters();
for (size_t i = 0; i < chapters.size(); i++) {
if (page >= chapters[i].startPage && page <= chapters[i].endPage) {
return static_cast<int>(i);
}
}
return 0;
}
void XtcReaderChapterSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<XtcReaderChapterSelectionActivity*>(param);
self->displayTaskLoop();
}
void XtcReaderChapterSelectionActivity::onEnter() {
Activity::onEnter();
if (!xtc) {
return;
}
renderingMutex = xSemaphoreCreateMutex();
selectorIndex = findChapterIndexForPage(currentPage);
updateRequired = true;
xTaskCreate(&XtcReaderChapterSelectionActivity::taskTrampoline, "XtcReaderChapterSelectionActivityTask",
4096, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void XtcReaderChapterSelectionActivity::onExit() {
Activity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void XtcReaderChapterSelectionActivity::loop() {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto& chapters = xtc->getChapters();
if (!chapters.empty() && selectorIndex >= 0 && selectorIndex < static_cast<int>(chapters.size())) {
onSelectPage(chapters[selectorIndex].startPage);
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
} else if (prevReleased) {
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) {
return;
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + total) % total;
} else {
selectorIndex = (selectorIndex + total - 1) % total;
}
updateRequired = true;
} else if (nextReleased) {
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) {
return;
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % total;
} else {
selectorIndex = (selectorIndex + 1) % total;
}
updateRequired = true;
}
}
void XtcReaderChapterSelectionActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void XtcReaderChapterSelectionActivity::renderScreen() {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Select Chapter", true, EpdFontFamily::BOLD);
const auto& chapters = xtc->getChapters();
if (chapters.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, 120, "No chapters");
renderer.displayBuffer();
return;
}
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
for (int i = pageStartIndex; i < static_cast<int>(chapters.size()) && i < pageStartIndex + pageItems; i++) {
const auto& chapter = chapters[i];
const char* title = chapter.name.empty() ? "Unnamed" : chapter.name.c_str();
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex);
}
renderer.displayBuffer();
}

View File

@@ -0,0 +1,41 @@
#pragma once
#include <Xtc.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <memory>
#include "../Activity.h"
class XtcReaderChapterSelectionActivity final : public Activity {
std::shared_ptr<Xtc> xtc;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
uint32_t currentPage = 0;
int selectorIndex = 0;
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void(uint32_t newPage)> onSelectPage;
int getPageItems() const;
int findChapterIndexForPage(uint32_t page) const;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
public:
explicit XtcReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::shared_ptr<Xtc>& xtc, uint32_t currentPage,
const std::function<void()>& onGoBack,
const std::function<void(uint32_t newPage)>& onSelectPage)
: Activity("XtcReaderChapterSelection", renderer, mappedInput),
xtc(xtc),
currentPage(currentPage),
onGoBack(onGoBack),
onSelectPage(onSelectPage) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@@ -1,11 +1,11 @@
#include "OtaUpdateActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include <WiFi.h>
#include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h"
#include "config.h"
#include "fontIds.h"
#include "network/OtaUpdater.h"
void OtaUpdateActivity::taskTrampoline(void* param) {
@@ -72,7 +72,7 @@ void OtaUpdateActivity::onEnter() {
// Launch WiFi selection subactivity
Serial.printf("[%lu] [OTA] Launching WifiSelectionActivity...\n", millis());
enterNewActivity(new WifiSelectionActivity(renderer, inputManager,
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
}
@@ -124,60 +124,56 @@ void OtaUpdateActivity::render() {
lastUpdaterPercentage = static_cast<int>(updaterProgress * 100);
}
const auto pageHeight = renderer.getScreenHeight();
const auto pageWidth = renderer.getScreenWidth();
renderer.clearScreen();
renderer.drawCenteredText(READER_FONT_ID, 10, "Update", true, BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Update", true, EpdFontFamily::BOLD);
if (state == CHECKING_FOR_UPDATE) {
renderer.drawCenteredText(UI_FONT_ID, 300, "Checking for update...", true, BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 300, "Checking for update...", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return;
}
if (state == WAITING_CONFIRMATION) {
renderer.drawCenteredText(UI_FONT_ID, 200, "New update available!", true, BOLD);
renderer.drawText(UI_FONT_ID, 20, 250, "Current Version: " CROSSPOINT_VERSION);
renderer.drawText(UI_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str());
renderer.drawCenteredText(UI_10_FONT_ID, 200, "New update available!", true, EpdFontFamily::BOLD);
renderer.drawText(UI_10_FONT_ID, 20, 250, "Current Version: " CROSSPOINT_VERSION);
renderer.drawText(UI_10_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str());
renderer.drawRect(25, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Cancel")) / 2, pageHeight - 35,
"Cancel");
renderer.drawRect(130, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Update")) / 2, pageHeight - 35,
"Update");
const auto labels = mappedInput.mapLabels("Cancel", "Update", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
}
if (state == UPDATE_IN_PROGRESS) {
renderer.drawCenteredText(UI_FONT_ID, 310, "Updating...", true, BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 310, "Updating...", true, EpdFontFamily::BOLD);
renderer.drawRect(20, 350, pageWidth - 40, 50);
renderer.fillRect(24, 354, static_cast<int>(updaterProgress * static_cast<float>(pageWidth - 44)), 42);
renderer.drawCenteredText(UI_FONT_ID, 420, (std::to_string(static_cast<int>(updaterProgress * 100)) + "%").c_str());
renderer.drawCenteredText(UI_10_FONT_ID, 420,
(std::to_string(static_cast<int>(updaterProgress * 100)) + "%").c_str());
renderer.drawCenteredText(
UI_FONT_ID, 440, (std::to_string(updater.processedSize) + " / " + std::to_string(updater.totalSize)).c_str());
UI_10_FONT_ID, 440,
(std::to_string(updater.processedSize) + " / " + std::to_string(updater.totalSize)).c_str());
renderer.displayBuffer();
return;
}
if (state == NO_UPDATE) {
renderer.drawCenteredText(UI_FONT_ID, 300, "No update available", true, BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 300, "No update available", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return;
}
if (state == FAILED) {
renderer.drawCenteredText(UI_FONT_ID, 300, "Update failed", true, BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 300, "Update failed", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return;
}
if (state == FINISHED) {
renderer.drawCenteredText(UI_FONT_ID, 300, "Update complete", true, BOLD);
renderer.drawCenteredText(UI_FONT_ID, 350, "Press and hold power button to turn back on");
renderer.drawCenteredText(UI_10_FONT_ID, 300, "Update complete", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 350, "Press and hold power button to turn back on");
renderer.displayBuffer();
state = SHUTTING_DOWN;
return;
@@ -191,7 +187,7 @@ void OtaUpdateActivity::loop() {
}
if (state == WAITING_CONFIRMATION) {
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
Serial.printf("[%lu] [OTA] New update available, starting download...\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = UPDATE_IN_PROGRESS;
@@ -215,7 +211,7 @@ void OtaUpdateActivity::loop() {
updateRequired = true;
}
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
goBack();
}
@@ -223,14 +219,14 @@ void OtaUpdateActivity::loop() {
}
if (state == FAILED) {
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
goBack();
}
return;
}
if (state == NO_UPDATE) {
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
goBack();
}
return;

View File

@@ -35,9 +35,11 @@ class OtaUpdateActivity : public ActivityWithSubactivity {
void render();
public:
explicit OtaUpdateActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& goBack)
: ActivityWithSubactivity("OtaUpdate", renderer, inputManager), goBack(goBack), updater() {}
explicit OtaUpdateActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& goBack)
: ActivityWithSubactivity("OtaUpdate", renderer, mappedInput), goBack(goBack), updater() {}
void onEnter() override;
void onExit() override;
void loop() override;
bool preventAutoSleep() override { return state == CHECKING_FOR_UPDATE || state == UPDATE_IN_PROGRESS; }
};

View File

@@ -1,22 +1,53 @@
#include "SettingsActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "OtaUpdateActivity.h"
#include "config.h"
#include "fontIds.h"
// Define the static settings list
namespace {
constexpr int settingsCount = 5;
constexpr int settingsCount = 15;
const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
{"Status Bar", SettingType::ENUM, &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}},
{"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}},
{"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}},
{"Check for updates", SettingType::ACTION, nullptr, {}},
{"Hyphenation", SettingType::TOGGLE, &CrossPointSettings::hyphenationEnabled, {}}};
{"Reading Orientation",
SettingType::ENUM,
&CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}},
{"Front Button Layout",
SettingType::ENUM,
&CrossPointSettings::frontButtonLayout,
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}},
{"Side Button Layout (reader)",
SettingType::ENUM,
&CrossPointSettings::sideButtonLayout,
{"Prev, Next", "Next, Prev"}},
{"Reader Font Family",
SettingType::ENUM,
&CrossPointSettings::fontFamily,
{"Bookerly", "Noto Sans", "Open Dyslexic"}},
{"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}},
{"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}},
{"Reader Paragraph Alignment",
SettingType::ENUM,
&CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right"}},
{"Hyphenation", SettingType::TOGGLE, &CrossPointSettings::hyphenationEnabled, {}}},
{"Time to Sleep",
SettingType::ENUM,
&CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}},
{"Refresh Frequency",
SettingType::ENUM,
&CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}},
{"Check for updates", SettingType::ACTION, nullptr, {}};
} // namespace
void SettingsActivity::taskTrampoline(void* param) {
@@ -63,24 +94,26 @@ void SettingsActivity::loop() {
}
// Handle actions with early return
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
toggleCurrentSetting();
updateRequired = true;
return;
}
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
SETTINGS.saveToFile();
onGoHome();
return;
}
// Handle navigation
if (inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
// Move selection up (with wrap-around)
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1);
updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT)) {
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
// Move selection down
if (selectedSettingIndex < settingsCount - 1) {
selectedSettingIndex++;
@@ -108,7 +141,7 @@ void SettingsActivity::toggleCurrentSetting() {
if (std::string(setting.name) == "Check for updates") {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new OtaUpdateActivity(renderer, inputManager, [this] {
enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
@@ -138,40 +171,42 @@ void SettingsActivity::displayTaskLoop() {
void SettingsActivity::render() const {
renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 10, "Settings", true, BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD);
// Draw selection
renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30);
// Draw all settings
for (int i = 0; i < settingsCount; i++) {
const int settingY = 60 + i * 30; // 30 pixels between settings
// Draw selection indicator for the selected setting
if (i == selectedSettingIndex) {
renderer.drawText(UI_FONT_ID, 5, settingY, ">");
}
// Draw setting name
renderer.drawText(UI_FONT_ID, 20, settingY, settingsList[i].name);
renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, i != selectedSettingIndex);
// Draw value based on setting type
std::string valueText = "";
if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) {
const bool value = SETTINGS.*(settingsList[i].valuePtr);
renderer.drawText(UI_FONT_ID, pageWidth - 80, settingY, value ? "ON" : "OFF");
valueText = value ? "ON" : "OFF";
} else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(settingsList[i].valuePtr);
auto valueText = settingsList[i].enumValues[value];
const auto width = renderer.getTextWidth(UI_FONT_ID, valueText.c_str());
renderer.drawText(UI_FONT_ID, pageWidth - 50 - width, settingY, valueText.c_str());
valueText = settingsList[i].enumValues[value];
}
const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), i != selectedSettingIndex);
}
// Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit");
// Draw version text above button hints
renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
pageHeight - 30, CROSSPOINT_VERSION);
pageHeight - 60, CROSSPOINT_VERSION);
// Draw help text
const auto labels = mappedInput.mapLabels("« Save", "Toggle", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Always use standard refresh for settings screen
renderer.displayBuffer();

View File

@@ -34,8 +34,9 @@ class SettingsActivity final : public ActivityWithSubactivity {
void toggleCurrentSetting();
public:
explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onGoHome)
: ActivityWithSubactivity("Settings", renderer, inputManager), onGoHome(onGoHome) {}
explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onGoHome)
: ActivityWithSubactivity("Settings", renderer, mappedInput), onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@@ -2,15 +2,15 @@
#include <GfxRenderer.h>
#include "config.h"
#include "fontIds.h"
void FullScreenMessageActivity::onEnter() {
Activity::onEnter();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (GfxRenderer::getScreenHeight() - height) / 2;
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (renderer.getScreenHeight() - height) / 2;
renderer.clearScreen();
renderer.drawCenteredText(UI_FONT_ID, top, text.c_str(), true, style);
renderer.drawCenteredText(UI_10_FONT_ID, top, text.c_str(), true, style);
renderer.displayBuffer(refreshMode);
}

View File

@@ -9,14 +9,14 @@
class FullScreenMessageActivity final : public Activity {
std::string text;
EpdFontStyle style;
EpdFontFamily::Style style;
EInkDisplay::RefreshMode refreshMode;
public:
explicit FullScreenMessageActivity(GfxRenderer& renderer, InputManager& inputManager, std::string text,
const EpdFontStyle style = REGULAR,
explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text,
const EpdFontFamily::Style style = EpdFontFamily::REGULAR,
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH)
: Activity("FullScreenMessage", renderer, inputManager),
: Activity("FullScreenMessage", renderer, mappedInput),
text(std::move(text)),
style(style),
refreshMode(refreshMode) {}

View File

@@ -1,6 +1,7 @@
#include "KeyboardEntryActivity.h"
#include "../../config.h"
#include "MappedInputManager.h"
#include "fontIds.h"
// Keyboard layouts - lowercase
const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = {
@@ -10,41 +11,55 @@ const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = {
// Keyboard layouts - uppercase/symbols
const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"",
"ZXCVBNM<>?", "^ _____<OK"};
"ZXCVBNM<>?", "SPECIAL ROW"};
void KeyboardEntryActivity::setText(const std::string& newText) {
text = newText;
if (maxLength > 0 && text.length() > maxLength) {
text.resize(maxLength);
}
void KeyboardEntryActivity::taskTrampoline(void* param) {
auto* self = static_cast<KeyboardEntryActivity*>(param);
self->displayTaskLoop();
}
void KeyboardEntryActivity::reset(const std::string& newTitle, const std::string& newInitialText) {
if (!newTitle.empty()) {
title = newTitle;
void KeyboardEntryActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
text = newInitialText;
selectedRow = 0;
selectedCol = 0;
shiftActive = false;
complete = false;
cancelled = false;
}
void KeyboardEntryActivity::onEnter() {
Activity::onEnter();
// Reset state when entering the activity
complete = false;
cancelled = false;
renderingMutex = xSemaphoreCreateMutex();
// Trigger first update
updateRequired = true;
xTaskCreate(&KeyboardEntryActivity::taskTrampoline, "KeyboardEntryActivity",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void KeyboardEntryActivity::loop() {
handleInput();
render(10);
void KeyboardEntryActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
int KeyboardEntryActivity::getRowLength(int row) const {
int KeyboardEntryActivity::getRowLength(const int row) const {
if (row < 0 || row >= NUM_ROWS) return 0;
// Return actual length of each row based on keyboard layout
@@ -58,7 +73,7 @@ int KeyboardEntryActivity::getRowLength(int row) const {
case 3:
return 10; // zxcvbnm,./
case 4:
return 10; // ^, space (5 wide), backspace, OK (2 wide)
return 10; // caps (2 wide), space (5 wide), backspace (2 wide), OK
default:
return 0;
}
@@ -75,8 +90,8 @@ char KeyboardEntryActivity::getSelectedChar() const {
void KeyboardEntryActivity::handleKeyPress() {
// Handle special row (bottom row with shift, space, backspace, done)
if (selectedRow == SHIFT_ROW) {
if (selectedCol == SHIFT_COL) {
if (selectedRow == SPECIAL_ROW) {
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// Shift toggle
shiftActive = !shiftActive;
return;
@@ -90,7 +105,7 @@ void KeyboardEntryActivity::handleKeyPress() {
return;
}
if (selectedCol == BACKSPACE_COL) {
if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// Backspace
if (!text.empty()) {
text.pop_back();
@@ -100,7 +115,6 @@ void KeyboardEntryActivity::handleKeyPress() {
if (selectedCol >= DONE_COL) {
// Done button
complete = true;
if (onComplete) {
onComplete(text);
}
@@ -109,42 +123,61 @@ void KeyboardEntryActivity::handleKeyPress() {
}
// Regular character
char c = getSelectedChar();
if (c != '\0' && c != '^' && c != '_' && c != '<') {
if (maxLength == 0 || text.length() < maxLength) {
text += c;
// Auto-disable shift after typing a letter
if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) {
shiftActive = false;
}
const char c = getSelectedChar();
if (c == '\0') {
return;
}
if (maxLength == 0 || text.length() < maxLength) {
text += c;
// Auto-disable shift after typing a letter
if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) {
shiftActive = false;
}
}
}
bool KeyboardEntryActivity::handleInput() {
if (complete || cancelled) {
return false;
}
bool handled = false;
void KeyboardEntryActivity::loop() {
// Navigation
if (inputManager.wasPressed(InputManager::BTN_UP)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
if (selectedRow > 0) {
selectedRow--;
// Clamp column to valid range for new row
int maxCol = getRowLength(selectedRow) - 1;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
handled = true;
} else if (inputManager.wasPressed(InputManager::BTN_DOWN)) {
updateRequired = true;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (selectedRow < NUM_ROWS - 1) {
selectedRow++;
int maxCol = getRowLength(selectedRow) - 1;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
handled = true;
} else if (inputManager.wasPressed(InputManager::BTN_LEFT)) {
updateRequired = true;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
// Special bottom row case
if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// In shift key, do nothing
} else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// In space bar, move to shift
selectedCol = SHIFT_COL;
} else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// In backspace, move to space
selectedCol = SPACE_COL;
} else if (selectedCol >= DONE_COL) {
// At done button, move to backspace
selectedCol = BACKSPACE_COL;
}
updateRequired = true;
return;
}
if (selectedCol > 0) {
selectedCol--;
} else if (selectedRow > 0) {
@@ -152,9 +185,31 @@ bool KeyboardEntryActivity::handleInput() {
selectedRow--;
selectedCol = getRowLength(selectedRow) - 1;
}
handled = true;
} else if (inputManager.wasPressed(InputManager::BTN_RIGHT)) {
int maxCol = getRowLength(selectedRow) - 1;
updateRequired = true;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case
if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// In shift key, move to space
selectedCol = SPACE_COL;
} else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// In space bar, move to backspace
selectedCol = BACKSPACE_COL;
} else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// In backspace, move to done
selectedCol = DONE_COL;
} else if (selectedCol >= DONE_COL) {
// At done button, do nothing
}
updateRequired = true;
return;
}
if (selectedCol < maxCol) {
selectedCol++;
} else if (selectedRow < NUM_ROWS - 1) {
@@ -162,36 +217,35 @@ bool KeyboardEntryActivity::handleInput() {
selectedRow++;
selectedCol = 0;
}
handled = true;
updateRequired = true;
}
// Selection
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
handleKeyPress();
handled = true;
updateRequired = true;
}
// Cancel
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
cancelled = true;
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
if (onCancel) {
onCancel();
}
handled = true;
updateRequired = true;
}
return handled;
}
void KeyboardEntryActivity::render(int startY) const {
const auto pageWidth = GfxRenderer::getScreenWidth();
void KeyboardEntryActivity::render() const {
const auto pageWidth = renderer.getScreenWidth();
renderer.clearScreen();
// Draw title
renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR);
renderer.drawCenteredText(UI_10_FONT_ID, startY, title.c_str());
// Draw input field
int inputY = startY + 22;
renderer.drawText(UI_FONT_ID, 10, inputY, "[");
const int inputY = startY + 22;
renderer.drawText(UI_10_FONT_ID, 10, inputY, "[");
std::string displayText;
if (isPassword) {
@@ -204,33 +258,33 @@ void KeyboardEntryActivity::render(int startY) const {
displayText += "_";
// Truncate if too long for display - use actual character width from font
int charWidth = renderer.getSpaceWidth(UI_FONT_ID);
if (charWidth < 1) charWidth = 8; // Fallback to approximate width
int maxDisplayLen = (pageWidth - 40) / charWidth;
int approxCharWidth = renderer.getSpaceWidth(UI_10_FONT_ID);
if (approxCharWidth < 1) approxCharWidth = 8; // Fallback to approximate width
const int maxDisplayLen = (pageWidth - 40) / approxCharWidth;
if (displayText.length() > static_cast<size_t>(maxDisplayLen)) {
displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3);
}
renderer.drawText(UI_FONT_ID, 20, inputY, displayText.c_str());
renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]");
renderer.drawText(UI_10_FONT_ID, 20, inputY, displayText.c_str());
renderer.drawText(UI_10_FONT_ID, pageWidth - 15, inputY, "]");
// Draw keyboard - use compact spacing to fit 5 rows on screen
int keyboardStartY = inputY + 25;
const int keyWidth = 18;
const int keyHeight = 18;
const int keySpacing = 3;
const int keyboardStartY = inputY + 25;
constexpr int keyWidth = 18;
constexpr int keyHeight = 18;
constexpr int keySpacing = 3;
const char* const* layout = shiftActive ? keyboardShift : keyboard;
// Calculate left margin to center the longest row (13 keys)
int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing);
int leftMargin = (pageWidth - maxRowWidth) / 2;
constexpr int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing);
const int leftMargin = (pageWidth - maxRowWidth) / 2;
for (int row = 0; row < NUM_ROWS; row++) {
int rowY = keyboardStartY + row * (keyHeight + keySpacing);
const int rowY = keyboardStartY + row * (keyHeight + keySpacing);
// Left-align all rows for consistent navigation
int startX = leftMargin;
const int startX = leftMargin;
// Handle bottom row (row 4) specially with proper multi-column keys
if (row == 4) {
@@ -240,69 +294,57 @@ void KeyboardEntryActivity::render(int startY) const {
int currentX = startX;
// CAPS key (logical col 0, spans 2 key widths)
int capsWidth = 2 * keyWidth + keySpacing;
bool capsSelected = (selectedRow == 4 && selectedCol == SHIFT_COL);
if (capsSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + capsWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 2, rowY, shiftActive ? "CAPS" : "caps");
currentX += capsWidth + keySpacing;
const bool capsSelected = (selectedRow == 4 && selectedCol >= SHIFT_COL && selectedCol < SPACE_COL);
renderItemWithSelector(currentX + 2, rowY, shiftActive ? "CAPS" : "caps", capsSelected);
currentX += 2 * (keyWidth + keySpacing);
// Space bar (logical cols 2-6, spans 5 key widths)
int spaceWidth = 5 * keyWidth + 4 * keySpacing;
bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL);
if (spaceSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + spaceWidth - 4, rowY, "]");
}
// Draw centered underscores for space bar
int spaceTextX = currentX + (spaceWidth / 2) - 12;
renderer.drawText(UI_FONT_ID, spaceTextX, rowY, "_____");
currentX += spaceWidth + keySpacing;
const bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL);
const int spaceTextWidth = renderer.getTextWidth(UI_10_FONT_ID, "_____");
const int spaceXWidth = 5 * (keyWidth + keySpacing);
const int spaceXPos = currentX + (spaceXWidth - spaceTextWidth) / 2;
renderItemWithSelector(spaceXPos, rowY, "_____", spaceSelected);
currentX += spaceXWidth;
// Backspace key (logical col 7, spans 2 key widths)
int bsWidth = 2 * keyWidth + keySpacing;
bool bsSelected = (selectedRow == 4 && selectedCol == BACKSPACE_COL);
if (bsSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + bsWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 6, rowY, "<-");
currentX += bsWidth + keySpacing;
const bool bsSelected = (selectedRow == 4 && selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL);
renderItemWithSelector(currentX + 2, rowY, "<-", bsSelected);
currentX += 2 * (keyWidth + keySpacing);
// OK button (logical col 9, spans 2 key widths)
int okWidth = 2 * keyWidth + keySpacing;
bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL);
if (okSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + okWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 8, rowY, "OK");
const bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL);
renderItemWithSelector(currentX + 2, rowY, "OK", okSelected);
} else {
// Regular rows: render each key individually
for (int col = 0; col < getRowLength(row); col++) {
int keyX = startX + col * (keyWidth + keySpacing);
// Get the character to display
char c = layout[row][col];
const char c = layout[row][col];
std::string keyLabel(1, c);
const int charWidth = renderer.getTextWidth(UI_10_FONT_ID, keyLabel.c_str());
// Draw selection highlight
bool isSelected = (row == selectedRow && col == selectedCol);
if (isSelected) {
renderer.drawText(UI_FONT_ID, keyX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, keyX + keyWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, keyX + 2, rowY, keyLabel.c_str());
const int keyX = startX + col * (keyWidth + keySpacing) + (keyWidth - charWidth) / 2;
const bool isSelected = row == selectedRow && col == selectedCol;
renderItemWithSelector(keyX, rowY, keyLabel.c_str(), isSelected);
}
}
}
// Draw help text at absolute bottom of screen (consistent with other screens)
const auto pageHeight = GfxRenderer::getScreenHeight();
renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK");
// Draw help text
const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Draw side button hints for Up/Down navigation
renderer.drawSideButtonHints(UI_10_FONT_ID, "Up", "Down");
renderer.displayBuffer();
}
void KeyboardEntryActivity::renderItemWithSelector(const int x, const int y, const char* item,
const bool isSelected) const {
if (isSelected) {
const int itemWidth = renderer.getTextWidth(UI_10_FONT_ID, item);
renderer.drawText(UI_10_FONT_ID, x - 6, y, "[");
renderer.drawText(UI_10_FONT_ID, x + itemWidth, y, "]");
}
renderer.drawText(UI_10_FONT_ID, x, y, item);
}

View File

@@ -1,9 +1,12 @@
#pragma once
#include <GfxRenderer.h>
#include <InputManager.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <utility>
#include "../Activity.h"
@@ -27,83 +30,47 @@ class KeyboardEntryActivity : public Activity {
/**
* Constructor
* @param renderer Reference to the GfxRenderer for drawing
* @param inputManager Reference to InputManager for handling input
* @param mappedInput Reference to MappedInputManager for handling input
* @param title Title to display above the keyboard
* @param initialText Initial text to show in the input field
* @param startY Y position to start rendering the keyboard
* @param maxLength Maximum length of input text (0 for unlimited)
* @param isPassword If true, display asterisks instead of actual characters
* @param onComplete Callback invoked when input is complete
* @param onCancel Callback invoked when input is cancelled
*/
KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, const std::string& title = "Enter Text",
const std::string& initialText = "", const size_t maxLength = 0, const bool isPassword = false)
: Activity("KeyboardEntry", renderer, inputManager),
title(title),
text(initialText),
explicit KeyboardEntryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::string title = "Enter Text", std::string initialText = "", const int startY = 10,
const size_t maxLength = 0, const bool isPassword = false,
OnCompleteCallback onComplete = nullptr, OnCancelCallback onCancel = nullptr)
: Activity("KeyboardEntry", renderer, mappedInput),
title(std::move(title)),
text(std::move(initialText)),
startY(startY),
maxLength(maxLength),
isPassword(isPassword) {}
/**
* Handle button input. Call this in your main loop.
* @return true if input was handled, false otherwise
*/
bool handleInput();
/**
* Render the keyboard at the specified Y position.
* @param startY Y-coordinate where keyboard rendering starts (default 10)
*/
void render(int startY = 10) const;
/**
* Get the current text entered by the user.
*/
const std::string& getText() const { return text; }
/**
* Set the current text.
*/
void setText(const std::string& newText);
/**
* Check if the user has completed text entry (pressed OK on Done).
*/
bool isComplete() const { return complete; }
/**
* Check if the user has cancelled text entry.
*/
bool isCancelled() const { return cancelled; }
/**
* Reset the keyboard state for reuse.
*/
void reset(const std::string& newTitle = "", const std::string& newInitialText = "");
/**
* Set callback for when input is complete.
*/
void setOnComplete(OnCompleteCallback callback) { onComplete = callback; }
/**
* Set callback for when input is cancelled.
*/
void setOnCancel(OnCancelCallback callback) { onCancel = callback; }
isPassword(isPassword),
onComplete(std::move(onComplete)),
onCancel(std::move(onCancel)) {}
// Activity overrides
void onEnter() override;
void onExit() override;
void loop() override;
private:
std::string title;
int startY;
std::string text;
size_t maxLength;
bool isPassword;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
// Keyboard state
int selectedRow = 0;
int selectedCol = 0;
bool shiftActive = false;
bool complete = false;
bool cancelled = false;
// Callbacks
OnCompleteCallback onComplete;
@@ -116,16 +83,17 @@ class KeyboardEntryActivity : public Activity {
static const char* const keyboardShift[NUM_ROWS];
// Special key positions (bottom row)
static constexpr int SHIFT_ROW = 4;
static constexpr int SPECIAL_ROW = 4;
static constexpr int SHIFT_COL = 0;
static constexpr int SPACE_ROW = 4;
static constexpr int SPACE_COL = 2;
static constexpr int BACKSPACE_ROW = 4;
static constexpr int BACKSPACE_COL = 7;
static constexpr int DONE_ROW = 4;
static constexpr int DONE_COL = 9;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
char getSelectedChar() const;
void handleKeyPress();
int getRowLength(int row) const;
void render() const;
void renderItemWithSelector(int x, int y, const char* item, bool isSelected) const;
};

View File

@@ -1,29 +0,0 @@
#pragma once
/**
* Generated with:
* ruby -rdigest -e 'puts [
* "./lib/EpdFont/builtinFonts/bookerly_2b.h",
* "./lib/EpdFont/builtinFonts/bookerly_bold_2b.h",
* "./lib/EpdFont/builtinFonts/bookerly_bold_italic_2b.h",
* "./lib/EpdFont/builtinFonts/bookerly_italic_2b.h",
* ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
*/
#define READER_FONT_ID 1818981670
/**
* Generated with:
* ruby -rdigest -e 'puts [
* "./lib/EpdFont/builtinFonts/ubuntu_10.h",
* "./lib/EpdFont/builtinFonts/ubuntu_bold_10.h",
* ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
*/
#define UI_FONT_ID (-1619831379)
/**
* Generated with:
* ruby -rdigest -e 'puts [
* "./lib/EpdFont/builtinFonts/pixelarial14.h",
* ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
*/
#define SMALL_FONT_ID 1482513144

18
src/fontIds.h Normal file
View File

@@ -0,0 +1,18 @@
// The contents of this file are generated by ./lib/EpdFont/scripts/build-font-ids.sh
#pragma once
#define BOOKERLY_12_FONT_ID (-142329172)
#define BOOKERLY_14_FONT_ID (104246423)
#define BOOKERLY_16_FONT_ID (1909382491)
#define BOOKERLY_18_FONT_ID (2056549737)
#define NOTOSANS_12_FONT_ID (-1646794343)
#define NOTOSANS_14_FONT_ID (-890242897)
#define NOTOSANS_16_FONT_ID (241925189)
#define NOTOSANS_18_FONT_ID (1503221336)
#define OPENDYSLEXIC_8_FONT_ID (875216341)
#define OPENDYSLEXIC_10_FONT_ID (-1234231183)
#define OPENDYSLEXIC_12_FONT_ID (1682200414)
#define OPENDYSLEXIC_14_FONT_ID (-1851285286)
#define UI_10_FONT_ID (-823541435)
#define UI_12_FONT_ID (-126318184)
#define SMALL_FONT_ID (-874196069)

View File

@@ -3,19 +3,14 @@
#include <Epub.h>
#include <GfxRenderer.h>
#include <InputManager.h>
#include <SD.h>
#include <SDCardManager.h>
#include <SPI.h>
#include <builtinFonts/bookerly_2b.h>
#include <builtinFonts/bookerly_bold_2b.h>
#include <builtinFonts/bookerly_bold_italic_2b.h>
#include <builtinFonts/bookerly_italic_2b.h>
#include <builtinFonts/pixelarial14.h>
#include <builtinFonts/ubuntu_10.h>
#include <builtinFonts/ubuntu_bold_10.h>
#include <builtinFonts/all.h>
#include "Battery.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "activities/boot_sleep/BootActivity.h"
#include "activities/boot_sleep/SleepActivity.h"
#include "activities/home/HomeActivity.h"
@@ -23,7 +18,7 @@
#include "activities/reader/ReaderActivity.h"
#include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
#include "config.h"
#include "fontIds.h"
#define SPI_FQ 40000000
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
@@ -36,30 +31,101 @@
#define UART0_RXD 20 // Used for USB connection detection
#define SD_SPI_CS 12
#define SD_SPI_MISO 7
EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY);
InputManager inputManager;
MappedInputManager mappedInputManager(inputManager);
GfxRenderer renderer(einkDisplay);
Activity* currentActivity;
// Fonts
EpdFont bookerlyFont(&bookerly_2b);
EpdFont bookerlyBoldFont(&bookerly_bold_2b);
EpdFont bookerlyItalicFont(&bookerly_italic_2b);
EpdFont bookerlyBoldItalicFont(&bookerly_bold_italic_2b);
EpdFontFamily bookerlyFontFamily(&bookerlyFont, &bookerlyBoldFont, &bookerlyItalicFont, &bookerlyBoldItalicFont);
EpdFont bookerly12RegularFont(&bookerly_12_regular);
EpdFont bookerly12BoldFont(&bookerly_12_bold);
EpdFont bookerly12ItalicFont(&bookerly_12_italic);
EpdFont bookerly12BoldItalicFont(&bookerly_12_bolditalic);
EpdFontFamily bookerly12FontFamily(&bookerly12RegularFont, &bookerly12BoldFont, &bookerly12ItalicFont,
&bookerly12BoldItalicFont);
EpdFont bookerly14RegularFont(&bookerly_14_regular);
EpdFont bookerly14BoldFont(&bookerly_14_bold);
EpdFont bookerly14ItalicFont(&bookerly_14_italic);
EpdFont bookerly14BoldItalicFont(&bookerly_14_bolditalic);
EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont, &bookerly14ItalicFont,
&bookerly14BoldItalicFont);
EpdFont bookerly16RegularFont(&bookerly_16_regular);
EpdFont bookerly16BoldFont(&bookerly_16_bold);
EpdFont bookerly16ItalicFont(&bookerly_16_italic);
EpdFont bookerly16BoldItalicFont(&bookerly_16_bolditalic);
EpdFontFamily bookerly16FontFamily(&bookerly16RegularFont, &bookerly16BoldFont, &bookerly16ItalicFont,
&bookerly16BoldItalicFont);
EpdFont bookerly18RegularFont(&bookerly_18_regular);
EpdFont bookerly18BoldFont(&bookerly_18_bold);
EpdFont bookerly18ItalicFont(&bookerly_18_italic);
EpdFont bookerly18BoldItalicFont(&bookerly_18_bolditalic);
EpdFontFamily bookerly18FontFamily(&bookerly18RegularFont, &bookerly18BoldFont, &bookerly18ItalicFont,
&bookerly18BoldItalicFont);
EpdFont smallFont(&pixelarial14);
EpdFont notosans12RegularFont(&notosans_12_regular);
EpdFont notosans12BoldFont(&notosans_12_bold);
EpdFont notosans12ItalicFont(&notosans_12_italic);
EpdFont notosans12BoldItalicFont(&notosans_12_bolditalic);
EpdFontFamily notosans12FontFamily(&notosans12RegularFont, &notosans12BoldFont, &notosans12ItalicFont,
&notosans12BoldItalicFont);
EpdFont notosans14RegularFont(&notosans_14_regular);
EpdFont notosans14BoldFont(&notosans_14_bold);
EpdFont notosans14ItalicFont(&notosans_14_italic);
EpdFont notosans14BoldItalicFont(&notosans_14_bolditalic);
EpdFontFamily notosans14FontFamily(&notosans14RegularFont, &notosans14BoldFont, &notosans14ItalicFont,
&notosans14BoldItalicFont);
EpdFont notosans16RegularFont(&notosans_16_regular);
EpdFont notosans16BoldFont(&notosans_16_bold);
EpdFont notosans16ItalicFont(&notosans_16_italic);
EpdFont notosans16BoldItalicFont(&notosans_16_bolditalic);
EpdFontFamily notosans16FontFamily(&notosans16RegularFont, &notosans16BoldFont, &notosans16ItalicFont,
&notosans16BoldItalicFont);
EpdFont notosans18RegularFont(&notosans_18_regular);
EpdFont notosans18BoldFont(&notosans_18_bold);
EpdFont notosans18ItalicFont(&notosans_18_italic);
EpdFont notosans18BoldItalicFont(&notosans_18_bolditalic);
EpdFontFamily notosans18FontFamily(&notosans18RegularFont, &notosans18BoldFont, &notosans18ItalicFont,
&notosans18BoldItalicFont);
EpdFont opendyslexic8RegularFont(&opendyslexic_8_regular);
EpdFont opendyslexic8BoldFont(&opendyslexic_8_bold);
EpdFont opendyslexic8ItalicFont(&opendyslexic_8_italic);
EpdFont opendyslexic8BoldItalicFont(&opendyslexic_8_bolditalic);
EpdFontFamily opendyslexic8FontFamily(&opendyslexic8RegularFont, &opendyslexic8BoldFont, &opendyslexic8ItalicFont,
&opendyslexic8BoldItalicFont);
EpdFont opendyslexic10RegularFont(&opendyslexic_10_regular);
EpdFont opendyslexic10BoldFont(&opendyslexic_10_bold);
EpdFont opendyslexic10ItalicFont(&opendyslexic_10_italic);
EpdFont opendyslexic10BoldItalicFont(&opendyslexic_10_bolditalic);
EpdFontFamily opendyslexic10FontFamily(&opendyslexic10RegularFont, &opendyslexic10BoldFont, &opendyslexic10ItalicFont,
&opendyslexic10BoldItalicFont);
EpdFont opendyslexic12RegularFont(&opendyslexic_12_regular);
EpdFont opendyslexic12BoldFont(&opendyslexic_12_bold);
EpdFont opendyslexic12ItalicFont(&opendyslexic_12_italic);
EpdFont opendyslexic12BoldItalicFont(&opendyslexic_12_bolditalic);
EpdFontFamily opendyslexic12FontFamily(&opendyslexic12RegularFont, &opendyslexic12BoldFont, &opendyslexic12ItalicFont,
&opendyslexic12BoldItalicFont);
EpdFont opendyslexic14RegularFont(&opendyslexic_14_regular);
EpdFont opendyslexic14BoldFont(&opendyslexic_14_bold);
EpdFont opendyslexic14ItalicFont(&opendyslexic_14_italic);
EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic);
EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont,
&opendyslexic14BoldItalicFont);
EpdFont smallFont(&notosans_8_regular);
EpdFontFamily smallFontFamily(&smallFont);
EpdFont ubuntu10Font(&ubuntu_10);
EpdFont ubuntuBold10Font(&ubuntu_bold_10);
EpdFontFamily ubuntuFontFamily(&ubuntu10Font, &ubuntuBold10Font);
EpdFont ui10RegularFont(&ubuntu_10_regular);
EpdFont ui10BoldFont(&ubuntu_10_bold);
EpdFontFamily ui10FontFamily(&ui10RegularFont, &ui10BoldFont);
EpdFont ui12RegularFont(&ubuntu_12_regular);
EpdFont ui12BoldFont(&ubuntu_12_bold);
EpdFontFamily ui12FontFamily(&ui12RegularFont, &ui12BoldFont);
// Auto-sleep timeout (10 minutes of inactivity)
constexpr unsigned long AUTO_SLEEP_TIMEOUT_MS = 10 * 60 * 1000;
// measurement of power button press duration calibration value
unsigned long t1 = 0;
unsigned long t2 = 0;
@@ -82,9 +148,11 @@ void verifyWakeupLongPress() {
// Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
const auto start = millis();
bool abort = false;
// It takes us some time to wake up from deep sleep, so we need to subtract that from the duration
uint16_t calibration = 25;
uint16_t calibratedPressDuration =
// Subtract the current time, because inputManager only starts counting the HeldTime from the first update()
// This way, we remove the time we already took to reach here from the duration,
// assuming the button was held until now from millis()==0 (i.e. device start time).
const uint16_t calibration = start;
const uint16_t calibratedPressDuration =
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
inputManager.update();
@@ -124,7 +192,7 @@ void waitForPowerRelease() {
// Enter deep sleep mode
void enterDeepSleep() {
exitActivity();
enterNewActivity(new SleepActivity(renderer, inputManager));
enterNewActivity(new SleepActivity(renderer, mappedInputManager));
einkDisplay.deepSleep();
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1);
@@ -139,30 +207,44 @@ void enterDeepSleep() {
void onGoHome();
void onGoToReader(const std::string& initialEpubPath) {
exitActivity();
enterNewActivity(new ReaderActivity(renderer, inputManager, initialEpubPath, onGoHome));
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, initialEpubPath, onGoHome));
}
void onGoToReaderHome() { onGoToReader(std::string()); }
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath); }
void onGoToFileTransfer() {
exitActivity();
enterNewActivity(new CrossPointWebServerActivity(renderer, inputManager, onGoHome));
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
}
void onGoToSettings() {
exitActivity();
enterNewActivity(new SettingsActivity(renderer, inputManager, onGoHome));
enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome));
}
void onGoHome() {
exitActivity();
enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings, onGoToFileTransfer));
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToReaderHome, onGoToSettings,
onGoToFileTransfer));
}
void setupDisplayAndFonts() {
einkDisplay.begin();
Serial.printf("[%lu] [ ] Display initialized\n", millis());
renderer.insertFont(READER_FONT_ID, bookerlyFontFamily);
renderer.insertFont(UI_FONT_ID, ubuntuFontFamily);
renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily);
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
renderer.insertFont(BOOKERLY_16_FONT_ID, bookerly16FontFamily);
renderer.insertFont(BOOKERLY_18_FONT_ID, bookerly18FontFamily);
renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily);
renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily);
renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily);
renderer.insertFont(NOTOSANS_18_FONT_ID, notosans18FontFamily);
renderer.insertFont(OPENDYSLEXIC_8_FONT_ID, opendyslexic8FontFamily);
renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, opendyslexic10FontFamily);
renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, opendyslexic12FontFamily);
renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, opendyslexic14FontFamily);
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
Serial.printf("[%lu] [ ] Fonts setup\n", millis());
}
@@ -176,8 +258,6 @@ void setup() {
Serial.begin(115200);
}
Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis());
inputManager.begin();
// Initialize pins
pinMode(BAT_GPIO0, INPUT);
@@ -187,11 +267,11 @@ void setup() {
// SD Card Initialization
// We need 6 open files concurrently when parsing a new chapter
if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ, "/sd", 6)) {
if (!SdMan.begin()) {
Serial.printf("[%lu] [ ] SD card initialization failed\n", millis());
setupDisplayAndFonts();
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "SD card error", BOLD));
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInputManager, "SD card error", EpdFontFamily::BOLD));
return;
}
@@ -200,10 +280,13 @@ void setup() {
// verify power button press duration after we've read settings.
verifyWakeupLongPress();
// First serial output only here to avoid timing inconsistencies for power button press duration verification
Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis());
setupDisplayAndFonts();
exitActivity();
enterNewActivity(new BootActivity(renderer, inputManager));
enterNewActivity(new BootActivity(renderer, mappedInputManager));
APP_STATE.loadFromFile();
if (APP_STATE.openEpubPath.empty()) {
@@ -233,14 +316,16 @@ void loop() {
lastMemPrint = millis();
}
// Check for any user activity (button press or release)
// Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis();
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased()) {
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() ||
(currentActivity && currentActivity->preventAutoSleep())) {
lastActivityTime = millis(); // Reset inactivity timer
}
if (millis() - lastActivityTime >= AUTO_SLEEP_TIMEOUT_MS) {
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), AUTO_SLEEP_TIMEOUT_MS);
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
if (millis() - lastActivityTime >= sleepTimeoutMs) {
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs);
enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return;

View File

@@ -2,7 +2,7 @@
#include <ArduinoJson.h>
#include <FsHelpers.h>
#include <SD.h>
#include <SDCardManager.h>
#include <WiFi.h>
#include <algorithm>
@@ -50,6 +50,14 @@ void CrossPointWebServer::begin() {
Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port);
server.reset(new WebServer(port));
// Disable WiFi sleep to improve responsiveness and prevent 'unreachable' errors.
// This is critical for reliable web server operation on ESP32.
WiFi.setSleep(false);
// Note: WebServer class doesn't have setNoDelay() in the standard ESP32 library.
// We rely on disabling WiFi sleep for responsiveness.
Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap());
if (!server) {
@@ -157,20 +165,21 @@ void CrossPointWebServer::handleStatus() const {
// Get correct IP based on AP vs STA mode
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
String json = "{";
json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\",";
json += "\"ip\":\"" + ipAddr + "\",";
json += "\"mode\":\"" + String(apMode ? "AP" : "STA") + "\",";
json += "\"rssi\":" + String(apMode ? 0 : WiFi.RSSI()) + ","; // RSSI not applicable in AP mode
json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ",";
json += "\"uptime\":" + String(millis() / 1000);
json += "}";
JsonDocument doc;
doc["version"] = CROSSPOINT_VERSION;
doc["ip"] = ipAddr;
doc["mode"] = apMode ? "AP" : "STA";
doc["rssi"] = apMode ? 0 : WiFi.RSSI();
doc["freeHeap"] = ESP.getFreeHeap();
doc["uptime"] = millis() / 1000;
String json;
serializeJson(doc, json);
server->send(200, "application/json", json);
}
void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const {
File root = SD.open(path);
FsFile root = SdMan.open(path);
if (!root) {
Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path);
return;
@@ -184,9 +193,11 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path);
File file = root.openNextFile();
FsFile file = root.openNextFile();
char name[128];
while (file) {
auto fileName = String(file.name());
file.getName(name, sizeof(name));
auto fileName = String(name);
// Skip hidden items (starting with ".")
bool shouldHide = fileName.startsWith(".");
@@ -218,6 +229,7 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
}
file.close();
yield(); // Yield to allow WiFi and other tasks to process during long scans
file = root.openNextFile();
}
root.close();
@@ -252,12 +264,15 @@ void CrossPointWebServer::handleFileListData() const {
char output[512];
constexpr size_t outputSize = sizeof(output);
bool seenFirst = false;
scanFiles(currentPath.c_str(), [this, &output, seenFirst](const FileInfo& info) mutable {
JsonDocument doc;
JsonDocument doc;
scanFiles(currentPath.c_str(), [this, &output, &doc, seenFirst](const FileInfo& info) mutable {
doc.clear();
doc["name"] = info.name;
doc["size"] = info.size;
doc["isDirectory"] = info.isDirectory;
doc["isEpub"] = info.isEpub;
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) {
// JSON output truncated; skip this entry to avoid sending malformed JSON
@@ -279,7 +294,7 @@ void CrossPointWebServer::handleFileListData() const {
}
// Static variables for upload handling
static File uploadFile;
static FsFile uploadFile;
static String uploadFileName;
static String uploadPath = "/";
static size_t uploadSize = 0;
@@ -334,13 +349,13 @@ void CrossPointWebServer::handleUpload() const {
filePath += uploadFileName;
// Check if file already exists
if (SD.exists(filePath.c_str())) {
if (SdMan.exists(filePath.c_str())) {
Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str());
SD.remove(filePath.c_str());
SdMan.remove(filePath.c_str());
}
// Open file for writing
if (!FsHelpers::openFileForWrite("WEB", filePath, uploadFile)) {
if (!SdMan.openFileForWrite("WEB", filePath, uploadFile)) {
uploadError = "Failed to create file on SD card";
Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str());
return;
@@ -393,7 +408,7 @@ void CrossPointWebServer::handleUpload() const {
String filePath = uploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += uploadFileName;
SD.remove(filePath.c_str());
SdMan.remove(filePath.c_str());
}
uploadError = "Upload aborted";
Serial.printf("[%lu] [WEB] Upload aborted\n", millis());
@@ -444,13 +459,13 @@ void CrossPointWebServer::handleCreateFolder() const {
Serial.printf("[%lu] [WEB] Creating folder: %s\n", millis(), folderPath.c_str());
// Check if already exists
if (SD.exists(folderPath.c_str())) {
if (SdMan.exists(folderPath.c_str())) {
server->send(400, "text/plain", "Folder already exists");
return;
}
// Create the folder
if (SD.mkdir(folderPath.c_str())) {
if (SdMan.mkdir(folderPath.c_str())) {
Serial.printf("[%lu] [WEB] Folder created successfully: %s\n", millis(), folderPath.c_str());
server->send(200, "text/plain", "Folder created: " + folderName);
} else {
@@ -500,7 +515,7 @@ void CrossPointWebServer::handleDelete() const {
}
// Check if item exists
if (!SD.exists(itemPath.c_str())) {
if (!SdMan.exists(itemPath.c_str())) {
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str());
server->send(404, "text/plain", "Item not found");
return;
@@ -512,10 +527,10 @@ void CrossPointWebServer::handleDelete() const {
if (itemType == "folder") {
// For folders, try to remove (will fail if not empty)
File dir = SD.open(itemPath.c_str());
FsFile dir = SdMan.open(itemPath.c_str());
if (dir && dir.isDirectory()) {
// Check if folder is empty
File entry = dir.openNextFile();
FsFile entry = dir.openNextFile();
if (entry) {
// Folder is not empty
entry.close();
@@ -526,10 +541,10 @@ void CrossPointWebServer::handleDelete() const {
}
dir.close();
}
success = SD.rmdir(itemPath.c_str());
success = SdMan.rmdir(itemPath.c_str());
} else {
// For files, use remove
success = SD.remove(itemPath.c_str());
success = SdMan.remove(itemPath.c_str());
}
if (success) {

View File

@@ -3,7 +3,6 @@
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <Update.h>
#include <WiFiClientSecure.h>
namespace {
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest";
@@ -27,7 +26,12 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
}
JsonDocument doc;
const DeserializationError error = deserializeJson(doc, *client);
JsonDocument filter;
filter["tag_name"] = true;
filter["assets"][0]["name"] = true;
filter["assets"][0]["browser_download_url"] = true;
filter["assets"][0]["size"] = true;
const DeserializationError error = deserializeJson(doc, *client, DeserializationOption::Filter(filter));
http.end();
if (error) {
Serial.printf("[%lu] [OTA] JSON parse failed: %s\n", millis(), error.c_str());
@@ -64,44 +68,41 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
return OK;
}
bool OtaUpdater::isUpdateNewer() {
bool OtaUpdater::isUpdateNewer() const {
if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) {
return false;
}
int currentMajor, currentMinor, currentPatch;
int latestMajor, latestMinor, latestPatch;
const auto currentVersion = CROSSPOINT_VERSION;
// semantic version check (only match on 3 segments)
const auto updateMajor = stoi(latestVersion.substr(0, latestVersion.find('.')));
const auto updateMinor = stoi(
latestVersion.substr(latestVersion.find('.') + 1, latestVersion.find_last_of('.') - latestVersion.find('.') - 1));
const auto updatePatch = stoi(latestVersion.substr(latestVersion.find_last_of('.') + 1));
sscanf(latestVersion.c_str(), "%d.%d.%d", &latestMajor, &latestMinor, &latestPatch);
sscanf(currentVersion, "%d.%d.%d", &currentMajor, &currentMinor, &currentPatch);
std::string currentVersion = CROSSPOINT_VERSION;
const auto currentMajor = stoi(currentVersion.substr(0, currentVersion.find('.')));
const auto currentMinor = stoi(currentVersion.substr(
currentVersion.find('.') + 1, currentVersion.find_last_of('.') - currentVersion.find('.') - 1));
const auto currentPatch = stoi(currentVersion.substr(currentVersion.find_last_of('.') + 1));
/*
* Compare major versions.
* If they differ, return true if latest major version greater than current major version
* otherwise return false.
*/
if (latestMajor != currentMajor) return latestMajor > currentMajor;
if (updateMajor > currentMajor) {
return true;
}
if (updateMajor < currentMajor) {
return false;
}
/*
* Compare minor versions.
* If they differ, return true if latest minor version greater than current minor version
* otherwise return false.
*/
if (latestMinor != currentMinor) return latestMinor > currentMinor;
if (updateMinor > currentMinor) {
return true;
}
if (updateMinor < currentMinor) {
return false;
}
if (updatePatch > currentPatch) {
return true;
}
return false;
/*
* Check patch versions.
*/
return latestPatch > currentPatch;
}
const std::string& OtaUpdater::getLatestVersion() { return latestVersion; }
const std::string& OtaUpdater::getLatestVersion() const { return latestVersion; }
OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function<void(size_t, size_t)>& onProgress) {
if (!isUpdateNewer()) {

View File

@@ -23,8 +23,8 @@ class OtaUpdater {
size_t totalSize = 0;
OtaUpdater() = default;
bool isUpdateNewer();
const std::string& getLatestVersion();
bool isUpdateNewer() const;
const std::string& getLatestVersion() const;
OtaUpdaterError checkForUpdate();
OtaUpdaterError installUpdate(const std::function<void(size_t, size_t)>& onProgress);
};

View File

@@ -341,6 +341,90 @@
width: 60px;
text-align: center;
}
/* Failed uploads banner */
.failed-uploads-banner {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 15px;
margin-bottom: 15px;
display: none;
}
.failed-uploads-banner.show {
display: block;
}
.failed-uploads-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.failed-uploads-title {
font-weight: 600;
color: #856404;
margin: 0;
}
.dismiss-btn {
background: none;
border: none;
font-size: 1.2em;
cursor: pointer;
color: #856404;
padding: 0;
line-height: 1;
}
.dismiss-btn:hover {
color: #533f03;
}
.failed-file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #ffe69c;
}
.failed-file-item:last-child {
border-bottom: none;
}
.failed-file-info {
flex: 1;
}
.failed-file-name {
font-weight: 500;
color: #856404;
}
.failed-file-error {
font-size: 0.85em;
color: #856404;
opacity: 0.8;
}
.retry-btn {
background-color: #ffc107;
color: #533f03;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
font-weight: 600;
}
.retry-btn:hover {
background-color: #e0a800;
}
.retry-all-btn {
background-color: #ffc107;
color: #533f03;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.95em;
font-weight: 600;
margin-top: 10px;
}
.retry-all-btn:hover {
background-color: #e0a800;
}
/* Delete modal */
.delete-warning {
color: #e74c3c;
@@ -505,6 +589,16 @@
</div>
</div>
<!-- Failed Uploads Banner -->
<div class="failed-uploads-banner" id="failedUploadsBanner">
<div class="failed-uploads-header">
<h3 class="failed-uploads-title">⚠️ Some files failed to upload</h3>
<button class="dismiss-btn" onclick="dismissFailedUploads()" title="Dismiss">&times;</button>
</div>
<div id="failedFilesList"></div>
<button class="retry-all-btn" onclick="retryAllFailedUploads()">Retry All Failed Uploads</button>
</div>
<div class="card">
<div class="contents-header">
<h2 class="contents-title">Contents</h2>
@@ -531,7 +625,7 @@
<h3>📤 Upload file</h3>
<div class="upload-form">
<p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p>
<input type="file" id="fileInput" onchange="validateFile()">
<input type="file" id="fileInput" onchange="validateFile()" multiple>
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
<div id="progress-container">
<div id="progress-bar"><div id="progress-fill"></div></div>
@@ -717,65 +811,183 @@
function validateFile() {
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const file = fileInput.files[0];
uploadBtn.disabled = !file;
const files = fileInput.files;
uploadBtn.disabled = !(files.length > 0);
}
function uploadFile() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
let failedUploadsGlobal = [];
if (!file) {
alert('Please select a file first!');
function uploadFile() {
const fileInput = document.getElementById('fileInput');
const files = Array.from(fileInput.files);
if (files.length === 0) {
alert('Please select at least one file!');
return;
}
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const uploadBtn = document.getElementById('uploadBtn');
progressContainer.style.display = 'block';
uploadBtn.disabled = true;
let currentIndex = 0;
const failedFiles = [];
function uploadNextFile() {
if (currentIndex >= files.length) {
// All files processed - show summary
if (failedFiles.length === 0) {
progressFill.style.backgroundColor = '#4caf50';
progressText.textContent = 'All uploads complete!';
setTimeout(() => {
closeUploadModal();
hydrate(); // Refresh file list instead of reloading
}, 1000);
} else {
progressFill.style.backgroundColor = '#e74c3c';
const failedList = failedFiles.map(f => f.name).join(', ');
progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`;
// Store failed files globally and show banner
failedUploadsGlobal = failedFiles;
setTimeout(() => {
closeUploadModal();
showFailedUploadsBanner();
hydrate(); // Refresh file list to show successfully uploaded files
}, 2000);
}
return;
}
const file = files[currentIndex];
const formData = new FormData();
formData.append('file', file);
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const uploadBtn = document.getElementById('uploadBtn');
progressContainer.style.display = 'block';
uploadBtn.disabled = true;
const xhr = new XMLHttpRequest();
// Include path as query parameter since multipart form data doesn't make
// form fields available until after file upload completes
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
xhr.upload.onprogress = function(e) {
progressFill.style.width = '0%';
progressFill.style.backgroundColor = '#4caf50';
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`;
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%';
progressText.textContent = 'Uploading: ' + percent + '%';
progressText.textContent =
`Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`;
}
};
xhr.onload = function() {
xhr.onload = function () {
if (xhr.status === 200) {
progressText.textContent = 'Upload complete!';
setTimeout(function() {
window.location.reload();
}, 1000);
currentIndex++;
uploadNextFile(); // upload next file
} else {
progressText.textContent = 'Upload failed: ' + xhr.responseText;
progressFill.style.backgroundColor = '#e74c3c';
uploadBtn.disabled = false;
// Track failure and continue with next file
failedFiles.push({ name: file.name, error: xhr.responseText, file: file });
currentIndex++;
uploadNextFile();
}
};
xhr.onerror = function() {
progressText.textContent = 'Upload failed - network error';
progressFill.style.backgroundColor = '#e74c3c';
uploadBtn.disabled = false;
xhr.onerror = function () {
// Track network error and continue with next file
failedFiles.push({ name: file.name, error: 'network error', file: file });
currentIndex++;
uploadNextFile();
};
xhr.send(formData);
}
uploadNextFile();
}
function showFailedUploadsBanner() {
const banner = document.getElementById('failedUploadsBanner');
const filesList = document.getElementById('failedFilesList');
filesList.innerHTML = '';
failedUploadsGlobal.forEach((failedFile, index) => {
const item = document.createElement('div');
item.className = 'failed-file-item';
item.innerHTML = `
<div class="failed-file-info">
<div class="failed-file-name">📄 ${escapeHtml(failedFile.name)}</div>
<div class="failed-file-error">Error: ${escapeHtml(failedFile.error)}</div>
</div>
<button class="retry-btn" onclick="retrySingleUpload(${index})">Retry</button>
`;
filesList.appendChild(item);
});
// Ensure retry all button is visible
const retryAllBtn = banner.querySelector('.retry-all-btn');
if (retryAllBtn) retryAllBtn.style.display = '';
banner.classList.add('show');
}
function dismissFailedUploads() {
const banner = document.getElementById('failedUploadsBanner');
banner.classList.remove('show');
failedUploadsGlobal = [];
}
function retrySingleUpload(index) {
const failedFile = failedUploadsGlobal[index];
if (!failedFile) return;
// Create a DataTransfer to set the file input
const dt = new DataTransfer();
dt.items.add(failedFile.file);
const fileInput = document.getElementById('fileInput');
fileInput.files = dt.files;
// Remove this file from failed list
failedUploadsGlobal.splice(index, 1);
// If no more failed files, hide banner
if (failedUploadsGlobal.length === 0) {
dismissFailedUploads();
}
// Open modal and trigger upload
openUploadModal();
validateFile();
}
function retryAllFailedUploads() {
if (failedUploadsGlobal.length === 0) return;
// Create a DataTransfer with all failed files
const dt = new DataTransfer();
failedUploadsGlobal.forEach(failedFile => {
dt.items.add(failedFile.file);
});
const fileInput = document.getElementById('fileInput');
fileInput.files = dt.files;
// Clear failed files list
failedUploadsGlobal = [];
dismissFailedUploads();
// Open modal and trigger upload
openUploadModal();
validateFile();
}
function createFolder() {
const folderName = document.getElementById('folderName').value.trim();