feat: add custom sleep timer picker (#2206)

## Summary

* **What is the goal of this PR?**
Mainly fixes #2196, but also allows users to set a sleep time of 1~30
min or to never sleep.

* **What changes are included?**

1. Revert changes from #1948 and #2137 (Sorry @Uri-Tauber)
2. Cherrypick changes from
https://github.com/uxjulia/CrossInk/commit/0cb91c3dd1c63f0559d4392fadc4a8340af5bdde
and
https://github.com/uxjulia/CrossInk/commit/4e6bd634b6ede51ff1a7107ba3e8efcd152bc6ee
for a timeout interval picker UI, the custom sleep time feature, and
migrate the existing setting in v1.3.0, `sleepTimeout`, to
`sleepTimeoutMinutes` (Thanks! @uxjulia)
3. Add a "Never" at the far right of the timeout interval picker

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY**_

---------

Co-authored-by: Julia <julia@uxj.io>
This commit is contained in:
WuTofu
2026-05-30 08:28:07 -04:00
committed by GitHub
co-authored by Julia
parent 66d9e4403a
commit ebf0413fe1
33 changed files with 316 additions and 58 deletions
+23 -17
View File
@@ -79,6 +79,22 @@ void CrossPointSettings::validateFrontButtonMapping(CrossPointSettings& settings
}
}
uint8_t CrossPointSettings::sleepTimeoutEnumToMinutes(const uint8_t legacyValue) {
switch (legacyValue) {
case SLEEP_1_MIN:
return 1;
case SLEEP_5_MIN:
return 5;
case SLEEP_15_MIN:
return 15;
case SLEEP_30_MIN:
return 30;
case SLEEP_10_MIN:
default:
return 10;
}
}
bool CrossPointSettings::saveToFile() const {
Storage.mkdir("/.crosspoint");
return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON);
@@ -186,7 +202,9 @@ bool CrossPointSettings::loadFromBinaryFile() {
if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, paragraphAlignment, PARAGRAPH_ALIGNMENT_COUNT);
if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, sleepTimeout, SLEEP_TIMEOUT_COUNT);
uint8_t legacySleepTimeout = SLEEP_10_MIN;
readAndValidate(inputFile, legacySleepTimeout, SLEEP_TIMEOUT_COUNT);
sleepTimeoutMinutes = sleepTimeoutEnumToMinutes(legacySleepTimeout);
if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, refreshFrequency, REFRESH_FREQUENCY_COUNT);
if (++settingsRead >= fileSettingsCount) break;
@@ -304,22 +322,10 @@ float CrossPointSettings::getReaderLineCompression() const {
}
unsigned long CrossPointSettings::getSleepTimeoutMs() const {
switch (sleepTimeout) {
case SLEEP_1_MIN:
return 1UL * 60 * 1000;
case SLEEP_3_MIN:
return 3UL * 60 * 1000;
case SLEEP_5_MIN:
return 5UL * 60 * 1000;
case SLEEP_10_MIN:
return 10UL * 60 * 1000;
case SLEEP_15_MIN:
return 15UL * 60 * 1000;
case SLEEP_30_MIN:
return 30UL * 60 * 1000;
default:
return 10UL * 60 * 1000;
}
if (sleepTimeoutMinutes >= SLEEP_TIMEOUT_NEVER_MINUTES) return 0UL;
const uint8_t minutes =
std::clamp(sleepTimeoutMinutes, MIN_SLEEP_TIMEOUT_MINUTES, static_cast<uint8_t>(SLEEP_TIMEOUT_NEVER_MINUTES - 1));
return static_cast<unsigned long>(minutes) * 60UL * 1000UL;
}
int CrossPointSettings::getRefreshFrequency() const {
+11 -7
View File
@@ -115,11 +115,10 @@ class CrossPointSettings {
// Auto-sleep timeout options (in minutes)
enum SLEEP_TIMEOUT {
SLEEP_1_MIN = 0,
SLEEP_3_MIN = 1,
SLEEP_5_MIN = 2,
SLEEP_10_MIN = 3,
SLEEP_15_MIN = 4,
SLEEP_30_MIN = 5,
SLEEP_5_MIN = 1,
SLEEP_10_MIN = 2,
SLEEP_15_MIN = 3,
SLEEP_30_MIN = 4,
SLEEP_TIMEOUT_COUNT
};
@@ -210,8 +209,8 @@ class CrossPointSettings {
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;
// Auto-sleep timeout setting (default 10 minutes). Legacy sleepTimeout enum values are migration-only.
uint8_t sleepTimeoutMinutes = 10;
// E-ink refresh frequency (default 15 pages)
uint8_t refreshFrequency = REFRESH_15;
uint8_t hyphenationEnabled = 0;
@@ -256,6 +255,10 @@ class CrossPointSettings {
// Get singleton instance
static CrossPointSettings& getInstance() { return instance; }
static constexpr uint8_t MIN_SLEEP_TIMEOUT_MINUTES = 1;
static constexpr uint8_t SLEEP_TIMEOUT_NEVER_MINUTES = 31;
static constexpr uint8_t MAX_SLEEP_TIMEOUT_MINUTES = SLEEP_TIMEOUT_NEVER_MINUTES;
// Callback to resolve SD card font IDs. Set by SdCardFontSystem::begin().
// Returns font ID or 0 if not found.
using SdFontIdResolver = int (*)(void* ctx, const char* familyName, uint8_t fontSize);
@@ -274,6 +277,7 @@ class CrossPointSettings {
bool loadFromFile();
static void validateFrontButtonMapping(CrossPointSettings& settings);
static uint8_t sleepTimeoutEnumToMinutes(uint8_t legacyValue);
private:
bool loadFromBinaryFile();
+7
View File
@@ -224,6 +224,13 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
}
}
if (doc["sleepTimeoutMinutes"].isNull() && !doc["sleepTimeout"].isNull()) {
const uint8_t legacyValue =
clamp(doc["sleepTimeout"] | (uint8_t)CrossPointSettings::SLEEP_10_MIN, CrossPointSettings::SLEEP_TIMEOUT_COUNT,
(uint8_t)CrossPointSettings::SLEEP_10_MIN);
s.sleepTimeoutMinutes = CrossPointSettings::sleepTimeoutEnumToMinutes(legacyValue);
if (needsResave) *needsResave = true;
}
// Front button remap — managed by RemapFrontButtons sub-activity, not in SettingsList.
using S = CrossPointSettings;
s.frontButtonBack =
+4 -4
View File
@@ -178,10 +178,10 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
// --- System ---
SettingInfo::Enum(StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeout,
{StrId::STR_MIN_1, StrId::STR_MIN_3, StrId::STR_MIN_5, StrId::STR_MIN_10, StrId::STR_MIN_15,
StrId::STR_MIN_30},
"sleepTimeout", StrId::STR_CAT_SYSTEM),
SettingInfo::Value(
StrId::STR_TIME_TO_SLEEP, &CrossPointSettings::sleepTimeoutMinutes,
{CrossPointSettings::MIN_SLEEP_TIMEOUT_MINUTES, CrossPointSettings::MAX_SLEEP_TIMEOUT_MINUTES, 1},
"sleepTimeoutMinutes", StrId::STR_CAT_SYSTEM),
SettingInfo::Toggle(StrId::STR_SHOW_HIDDEN_FILES, &CrossPointSettings::showHiddenFiles, "showHiddenFiles",
StrId::STR_CAT_SYSTEM),
SettingInfo::Toggle(StrId::STR_REMOVE_READ_FROM_RECENTS, &CrossPointSettings::removeReadBooksFromRecents,
+7 -2
View File
@@ -32,6 +32,10 @@ struct PercentResult {
int percent = 0;
};
struct IntervalResult {
uint32_t value = 0;
};
struct PageResult {
uint32_t page = 0;
};
@@ -55,8 +59,9 @@ struct FilePathResult {
std::string path;
};
using ResultVariant = std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult,
PageResult, ProgressChangeResult, NetworkModeResult, FootnoteResult, FilePathResult>;
using ResultVariant =
std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult, IntervalResult,
PageResult, ProgressChangeResult, NetworkModeResult, FootnoteResult, FilePathResult>;
struct ActivityResult {
bool isCancelled = false;
+44 -4
View File
@@ -3,6 +3,10 @@
#include <GfxRenderer.h>
#include <Logging.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include "ButtonRemapActivity.h"
#include "ClearCacheActivity.h"
#include "CrossPointSettings.h"
@@ -18,6 +22,7 @@
#include "SettingsList.h"
#include "StatusBarSettingsActivity.h"
#include "activities/network/WifiSelectionActivity.h"
#include "activities/util/IntervalSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -183,6 +188,11 @@ void SettingsActivity::toggleCurrentSetting() {
const bool sleepScreenChanged = setting.valuePtr == &CrossPointSettings::sleepScreen;
const bool quickResumeTimeoutChanged = setting.valuePtr == &CrossPointSettings::quickResumeSleepScreen;
if (setting.nameId == StrId::STR_TIME_TO_SLEEP) {
openSleepTimeoutPicker();
return;
}
if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {
// Toggle the boolean value using the member pointer
const bool currentValue = SETTINGS.*(setting.valuePtr);
@@ -286,6 +296,22 @@ void SettingsActivity::syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChan
}
}
void SettingsActivity::openSleepTimeoutPicker() {
startActivityForResult(
std::make_unique<IntervalSelectionActivity>(
renderer, mappedInput, "SleepTimeoutInterval", StrId::STR_TIME_TO_SLEEP, StrId::STR_SLEEP_TIMER_STEP_HINT,
SETTINGS.sleepTimeoutMinutes, CrossPointSettings::MIN_SLEEP_TIMEOUT_MINUTES,
CrossPointSettings::MAX_SLEEP_TIMEOUT_MINUTES, 1, 5, StrId::STR_SLEEP_TIMER_VALUE_FORMAT, false, true,
StrId::STR_SLEEP_NEVER),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
SETTINGS.sleepTimeoutMinutes = static_cast<uint8_t>(std::get<IntervalResult>(result.data).value);
SETTINGS.saveToFile();
}
requestUpdate();
});
}
void SettingsActivity::render(RenderLock&&) {
renderer.clearScreen();
@@ -330,16 +356,30 @@ void SettingsActivity::render(RenderLock&&) {
valueText = I18N.get(setting.enumValues[value]);
}
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
if (setting.nameId == StrId::STR_TIME_TO_SLEEP) {
char valueBuffer[32];
if (SETTINGS.sleepTimeoutMinutes >= CrossPointSettings::SLEEP_TIMEOUT_NEVER_MINUTES) {
valueText = tr(STR_SLEEP_NEVER);
} else {
snprintf(valueBuffer, sizeof(valueBuffer), tr(STR_SLEEP_TIMER_VALUE_FORMAT),
static_cast<unsigned int>(SETTINGS.*(setting.valuePtr)));
valueText = valueBuffer;
}
} else {
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
}
}
return valueText;
},
true);
// Draw help text
const auto confirmLabel = (selectedSettingIndex == 0)
? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])
: tr(STR_TOGGLE);
const auto confirmLabel =
(selectedSettingIndex == 0)
? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount])
: (selectedSettingIndex > 0 && (*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP
? tr(STR_SELECT)
: tr(STR_TOGGLE));
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
@@ -164,6 +164,7 @@ class SettingsActivity final : public Activity {
void enterCategory(int categoryIndex);
void toggleCurrentSetting();
void openSleepTimeoutPicker();
void rebuildSettingsLists();
void syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChanged, bool quickResumeTimeoutChanged);
@@ -0,0 +1,97 @@
#include "IntervalSelectionActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
#include <cstdio>
#include <utility>
#include "components/UITheme.h"
#include "fontIds.h"
int IntervalSelectionActivity::clampedValue(const int candidate) const {
return std::clamp(candidate, minValue, maxValue);
}
void IntervalSelectionActivity::onEnter() {
Activity::onEnter();
value = clampedValue(value);
requestUpdate();
}
void IntervalSelectionActivity::adjustValue(const int delta) {
value = clampedValue(value + delta);
requestUpdate();
}
void IntervalSelectionActivity::loop() {
if (ignoreConfirmRelease) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
ignoreConfirmRelease = false;
return;
}
if (!mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
ignoreConfirmRelease = false;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
setResult(IntervalResult{static_cast<uint32_t>(value)});
finish();
return;
}
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustValue(-smallStep); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustValue(smallStep); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { adjustValue(largeStep); });
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { adjustValue(-largeStep); });
}
void IntervalSelectionActivity::render(RenderLock&&) {
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 15, I18N.get(titleId), true, EpdFontFamily::BOLD);
char formattedValue[32];
if (maxBoundaryLabelId != StrId::STR_NONE_OPT && value == maxValue) {
snprintf(formattedValue, sizeof(formattedValue), "%s", I18N.get(maxBoundaryLabelId));
} else if (valueFormatId != StrId::STR_NONE_OPT) {
snprintf(formattedValue, sizeof(formattedValue), I18N.get(valueFormatId), static_cast<unsigned int>(value));
} else {
snprintf(formattedValue, sizeof(formattedValue), "%d", value);
}
renderer.drawCenteredText(UI_12_FONT_ID, 90, formattedValue, true, EpdFontFamily::BOLD);
const int screenWidth = renderer.getScreenWidth();
const int barWidth = std::min(360, std::max(0, screenWidth - 40));
constexpr int barHeight = 16;
const int barX = std::max(0, (screenWidth - barWidth) / 2);
const int barY = 140;
renderer.drawRect(barX, barY, barWidth, barHeight);
const int range = std::max(1, maxValue - minValue);
const int fillWidth = (barWidth - 4) * (value - minValue) / range;
if (fillWidth > 0) {
renderer.fillRect(barX + 2, barY + 2, fillWidth, barHeight - 4);
}
const int knobX = std::max(barX + 2, barX + 2 + fillWidth - 2);
renderer.fillRect(knobX, barY - 4, 4, barHeight + 8, true);
renderer.drawCenteredText(SMALL_FONT_ID, barY + 30, I18N.get(stepHintId), true);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "-", "+");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -0,0 +1,52 @@
#pragma once
#include <I18n.h>
#include "MappedInputManager.h"
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
class GfxRenderer;
class IntervalSelectionActivity final : public Activity {
public:
explicit IntervalSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const char* activityName,
StrId titleId, StrId stepHintId, int initialValue, int minValue, int maxValue,
int smallStep, int largeStep, StrId valueFormatId = StrId::STR_NONE_OPT,
bool readerActivity = false, bool ignoreInitialConfirmRelease = false,
StrId maxBoundaryLabelId = StrId::STR_NONE_OPT)
: Activity(activityName, renderer, mappedInput),
titleId(titleId),
stepHintId(stepHintId),
valueFormatId(valueFormatId),
maxBoundaryLabelId(maxBoundaryLabelId),
value(initialValue),
minValue(minValue),
maxValue(maxValue),
smallStep(smallStep),
largeStep(largeStep),
readerActivity(readerActivity),
ignoreConfirmRelease(ignoreInitialConfirmRelease) {}
void onEnter() override;
void loop() override;
void render(RenderLock&&) override;
bool isReaderActivity() const override { return readerActivity; }
private:
StrId titleId;
StrId stepHintId;
StrId valueFormatId;
StrId maxBoundaryLabelId;
int value;
int minValue;
int maxValue;
int smallStep;
int largeStep;
bool readerActivity;
bool ignoreConfirmRelease;
ButtonNavigator buttonNavigator;
void adjustValue(int delta);
int clampedValue(int candidate) const;
};
+1 -1
View File
@@ -574,7 +574,7 @@ void loop() {
}
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
if (millis() - lastActivityTime >= sleepTimeoutMs) {
if (sleepTimeoutMs > 0 && millis() - lastActivityTime >= sleepTimeoutMs) {
LOG_DBG("SLP", "Auto-sleep triggered after %lu ms of inactivity", sleepTimeoutMs);
enterDeepSleep(true);
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start