diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 69e1fccd..2bf1df41 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -50,6 +50,8 @@ void EpubReaderMenuActivity::onEnter() { void EpubReaderMenuActivity::onExit() { Activity::onExit(); } void EpubReaderMenuActivity::loop() { + if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; + // Handle navigation buttonNavigator.onNext([this] { selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast(menuItems.size())); @@ -64,14 +66,21 @@ void EpubReaderMenuActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { const auto selectedAction = menuItems[selectedIndex].action; if (selectedAction == MenuAction::ROTATE_SCREEN) { - // Cycle orientation preview locally; actual rotation happens on menu exit. - pendingOrientation = (pendingOrientation + 1) % orientationLabels.size(); + optionPopup.show(StrId::STR_ORIENTATION, orientationLabels.data(), static_cast(orientationLabels.size()), + pendingOrientation, [this](int idx) { + pendingOrientation = idx; + requestUpdate(); + }); requestUpdate(); return; } if (selectedAction == MenuAction::AUTO_PAGE_TURN) { - selectedPageTurnOption = (selectedPageTurnOption + 1) % pageTurnLabels.size(); + optionPopup.show(I18N.get(StrId::STR_AUTO_TURN_PAGES_PER_MIN), pageTurnLabels.data(), + static_cast(pageTurnLabels.size()), selectedPageTurnOption, [this](int idx) { + selectedPageTurnOption = idx; + requestUpdate(); + }); requestUpdate(); return; } @@ -90,6 +99,8 @@ void EpubReaderMenuActivity::loop() { } void EpubReaderMenuActivity::render(RenderLock&&) { + if (optionPopup.processRender(renderer, mappedInput)) return; + renderer.clearScreen(); auto metrics = UITheme::getInstance().getMetrics(); diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 285ab76e..1920ac8e 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -6,6 +6,7 @@ #include #include "activities/Activity.h" +#include "components/OptionPopup.h" #include "util/ButtonNavigator.h" class EpubReaderMenuActivity final : public Activity { @@ -49,6 +50,7 @@ class EpubReaderMenuActivity final : public Activity { int selectedIndex = 0; ButtonNavigator buttonNavigator; + OptionPopup optionPopup; std::string title = "Reader Menu"; uint8_t pendingOrientation = 0; uint8_t selectedPageTurnOption = 0; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 8c0a7ef6..4220d0ab 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -113,6 +113,8 @@ void SettingsActivity::onExit() { } void SettingsActivity::loop() { + if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; + bool hasChangedCategory = false; // Handle actions with early return @@ -203,6 +205,18 @@ void SettingsActivity::toggleCurrentSetting() { SETTINGS.*(setting.valuePtr) = !currentValue; } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { const uint8_t currentValue = SETTINGS.*(setting.valuePtr); + if (setting.enumValues.size() > 2) { + const auto valuePtr = setting.valuePtr; + optionPopup.show(setting.nameId, setting.enumValues.data(), static_cast(setting.enumValues.size()), + currentValue, [this, valuePtr, sleepScreenChanged, quickResumeTimeoutChanged](int idx) { + SETTINGS.*valuePtr = idx; + syncQuickResumeTimeoutForSleepScreen(sleepScreenChanged, quickResumeTimeoutChanged); + SETTINGS.saveToFile(); + rebuildSettingsLists(); + }); + requestUpdate(); + return; + } SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); } else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) { if (setting.nameId == StrId::STR_FONT_FAMILY) { @@ -218,6 +232,23 @@ void SettingsActivity::toggleCurrentSetting() { ? static_cast(setting.enumValues.size()) : static_cast(setting.enumStringValues.size()); const uint8_t cur = setting.valueGetter(); + if (totalValues > 2) { + const auto valueSetter = setting.valueSetter; + auto onSelect = [this, valueSetter, sleepScreenChanged, quickResumeTimeoutChanged](int idx) { + valueSetter(idx); + syncQuickResumeTimeoutForSleepScreen(sleepScreenChanged, quickResumeTimeoutChanged); + SETTINGS.saveToFile(); + rebuildSettingsLists(); + }; + if (!setting.enumStringValues.empty()) { + optionPopup.show(setting.nameId, setting.enumStringValues, cur, std::move(onSelect)); + } else { + optionPopup.show(setting.nameId, setting.enumValues.data(), static_cast(setting.enumValues.size()), cur, + std::move(onSelect)); + } + requestUpdate(); + return; + } setting.valueSetter((cur + 1) % totalValues); } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { const int8_t currentValue = SETTINGS.*(setting.valuePtr); @@ -319,6 +350,8 @@ void SettingsActivity::openSleepTimeoutPicker() { } void SettingsActivity::render(RenderLock&&) { + if (optionPopup.processRender(renderer, mappedInput)) return; + renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); @@ -386,6 +419,7 @@ void SettingsActivity::render(RenderLock&&) { : (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); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 5ec639a0..d8a790db 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -7,6 +7,7 @@ #include "CrossPointSettings.h" #include "activities/Activity.h" +#include "components/OptionPopup.h" #include "util/ButtonNavigator.h" enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING }; @@ -159,6 +160,8 @@ class SettingsActivity final : public Activity { bool preserveQuickResumeTimeoutOn = false; bool quickResumeTimeoutAutoEnabled = false; + OptionPopup optionPopup; + static constexpr int categoryCount = 4; static const StrId categoryNames[categoryCount]; diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index 4046fcb1..7bd755d6 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -125,6 +125,8 @@ void StatusBarSettingsActivity::onEnter() { void StatusBarSettingsActivity::onExit() { Activity::onExit(); } void StatusBarSettingsActivity::loop() { + if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { finish(); return; @@ -167,21 +169,35 @@ void StatusBarSettingsActivity::handleSelection() { SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2; break; case ITEM_PROGRESS_BAR: - SETTINGS.statusBarProgressBar = (SETTINGS.statusBarProgressBar + 1) % PROGRESS_BAR_ITEMS; - break; + optionPopup.show(StrId::STR_PROGRESS_BAR, progressBarNames, PROGRESS_BAR_ITEMS, SETTINGS.statusBarProgressBar, + [this](int idx) { + SETTINGS.statusBarProgressBar = idx; + SETTINGS.saveToFile(); + }); + return; case ITEM_PROGRESS_BAR_THICKNESS: - SETTINGS.statusBarProgressBarThickness = - (SETTINGS.statusBarProgressBarThickness + 1) % PROGRESS_BAR_THICKNESS_ITEMS; - break; + optionPopup.show(StrId::STR_PROGRESS_BAR_THICKNESS, progressBarThicknessNames, PROGRESS_BAR_THICKNESS_ITEMS, + SETTINGS.statusBarProgressBarThickness, [this](int idx) { + SETTINGS.statusBarProgressBarThickness = idx; + SETTINGS.saveToFile(); + }); + return; case ITEM_TITLE: - SETTINGS.statusBarTitle = (SETTINGS.statusBarTitle + 1) % TITLE_ITEMS; - break; + optionPopup.show(StrId::STR_TITLE, titleNames, TITLE_ITEMS, SETTINGS.statusBarTitle, [this](int idx) { + SETTINGS.statusBarTitle = idx; + SETTINGS.saveToFile(); + }); + return; case ITEM_BATTERY: SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2; break; case ITEM_XTC_STATUS_BAR: - SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS; - break; + optionPopup.show(StrId::STR_XTC_STATUS_BAR, xtcStatusBarNames, XTC_STATUS_BAR_ITEMS, SETTINGS.xtcStatusBarMode, + [this](int idx) { + SETTINGS.xtcStatusBarMode = idx; + SETTINGS.saveToFile(); + }); + return; case ITEM_CLOCK: SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % STATUS_BAR_CLOCK_ITEMS; break; @@ -202,6 +218,8 @@ void StatusBarSettingsActivity::handleSelection() { } void StatusBarSettingsActivity::render(RenderLock&&) { + if (optionPopup.processRender(renderer, mappedInput)) return; + renderer.clearScreen(); auto metrics = UITheme::getInstance().getMetrics(); diff --git a/src/activities/settings/StatusBarSettingsActivity.h b/src/activities/settings/StatusBarSettingsActivity.h index 7029767c..3fa0def2 100644 --- a/src/activities/settings/StatusBarSettingsActivity.h +++ b/src/activities/settings/StatusBarSettingsActivity.h @@ -4,6 +4,7 @@ #include #include "activities/Activity.h" +#include "components/OptionPopup.h" #include "util/ButtonNavigator.h" // Reader status bar configuration activity @@ -19,6 +20,7 @@ class StatusBarSettingsActivity final : public Activity { private: ButtonNavigator buttonNavigator; + OptionPopup optionPopup; int selectedIndex = 0; // Decided in onEnter() based on halClock.isAvailable() so clock entries are hidden on X4. diff --git a/src/components/OptionPopup.h b/src/components/OptionPopup.h new file mode 100644 index 00000000..e98bfbd0 --- /dev/null +++ b/src/components/OptionPopup.h @@ -0,0 +1,95 @@ +#pragma once +#include + +#include +#include +#include + +#include "GfxRenderer.h" +#include "MappedInputManager.h" +#include "components/UITheme.h" + +class OptionPopup { + public: + void show(StrId titleId, const StrId* optionIds, int optionCount, int currentIndex, + std::function onSelect) { + title = I18N.get(titleId); + ownedStrings.resize(optionCount); + for (int i = 0; i < optionCount; i++) { + ownedStrings[i] = I18N.get(optionIds[i]); + } + selectedIndex = currentIndex; + onSelectCallback = std::move(onSelect); + active = true; + } + + void show(const char* titleStr, const char* const* options, int optionCount, int currentIndex, + std::function onSelect) { + title = titleStr; + ownedStrings.resize(optionCount); + for (int i = 0; i < optionCount; i++) { + ownedStrings[i] = options[i]; + } + selectedIndex = currentIndex; + onSelectCallback = std::move(onSelect); + active = true; + } + + void show(StrId titleId, const std::vector& options, int currentIndex, + std::function onSelect) { + title = I18N.get(titleId); + ownedStrings = options; + selectedIndex = currentIndex; + onSelectCallback = std::move(onSelect); + active = true; + } + + bool handleInput(MappedInputManager& input, const std::function& requestUpdate) { + if (!active) return false; + + const int count = static_cast(ownedStrings.size()); + if (input.wasPressed(MappedInputManager::Button::Up) || input.wasPressed(MappedInputManager::Button::Left)) { + selectedIndex = (selectedIndex - 1 + count) % count; + requestUpdate(); + return true; + } else if (input.wasPressed(MappedInputManager::Button::Down) || + input.wasPressed(MappedInputManager::Button::Right)) { + selectedIndex = (selectedIndex + 1) % count; + requestUpdate(); + return true; + } else if (input.wasPressed(MappedInputManager::Button::Confirm)) { + active = false; + if (onSelectCallback) onSelectCallback(selectedIndex); + requestUpdate(); + return true; + } else if (input.wasPressed(MappedInputManager::Button::Back)) { + active = false; + requestUpdate(); + return true; + } + return true; + } + + bool processRender(GfxRenderer& renderer, const MappedInputManager& input) const { + if (!active) return false; + const auto popupLabels = input.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, popupLabels.btn1, popupLabels.btn2, popupLabels.btn3, popupLabels.btn4); + render(renderer); + renderer.displayBuffer(); + return true; + } + + void render(const GfxRenderer& renderer) const { + if (!active) return; + GUI.drawOptionPopup(renderer, title.c_str(), ownedStrings, selectedIndex); + } + + bool isActive() const { return active; } + + private: + bool active = false; + std::string title; + std::vector ownedStrings; + int selectedIndex = 0; + std::function onSelectCallback; +}; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 735f1477..b2bbc21c 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -1005,3 +1005,99 @@ void BaseTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const ch rect.y + metrics.keyboardSecondaryLabelTopPadding, secondaryLabel, !invert); } } + +void BaseTheme::drawOptionPopup(const GfxRenderer& renderer, const char* title, const std::vector& options, + int selectedIndex) const { + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + const int optionFontId = metrics.optionPopupUseSmallFont ? UI_10_FONT_ID : UI_12_FONT_ID; + const EpdFontFamily::Style optionStyle = + metrics.optionPopupOptionFontBold ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR; + + const int itemSpacing = metrics.optionPopupItemSpacing; + const int innerPadding = metrics.optionPopupInnerPadding; + const int selectionHPadding = metrics.optionPopupSelectionHPadding; + const int selectionVPadding = metrics.optionPopupSelectionVPadding; + + const int optionLineHeight = renderer.getLineHeight(optionFontId); + const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int rowHeight = optionLineHeight + selectionVPadding * 2; + + int maxTextWidth = renderer.getTextWidth(UI_12_FONT_ID, title, EpdFontFamily::BOLD); + for (const auto& opt : options) { + int w = renderer.getTextWidth(optionFontId, opt.c_str(), optionStyle); + if (w > maxTextWidth) maxTextWidth = w; + } + + const int optionCount = static_cast(options.size()); + const int listHeight = rowHeight * optionCount + itemSpacing * (optionCount - 1); + const int dialogW = std::min((maxTextWidth + innerPadding * 2 + selectionHPadding * 2) * 12 / 10, + pageWidth - metrics.optionPopupDialogSideMargin * 2); + const int contentHeight = titleLineHeight + metrics.optionPopupTitleGap + listHeight; + const int dialogH = contentHeight + innerPadding * 2; + const int dialogX = (pageWidth - dialogW) / 2; + const int dialogY = (pageHeight - dialogH) / 2; + + const int frameThickness = metrics.popupFrameThickness; + const int frameRadius = metrics.popupCornerRadius; + + if (frameRadius > 0) { + renderer.fillRoundedRect(dialogX - frameThickness, dialogY - frameThickness, dialogW + frameThickness * 2, + dialogH + frameThickness * 2, frameRadius + frameThickness, Color::White); + renderer.fillRoundedRect(dialogX, dialogY, dialogW, dialogH, frameRadius, Color::Black); + renderer.fillRoundedRect(dialogX + frameThickness, dialogY + frameThickness, dialogW - frameThickness * 2, + dialogH - frameThickness * 2, + frameRadius - frameThickness > 0 ? frameRadius - frameThickness : 0, Color::White); + } else { + renderer.fillRect(dialogX - frameThickness, dialogY - frameThickness, dialogW + frameThickness * 2, + dialogH + frameThickness * 2, true); + renderer.fillRect(dialogX, dialogY, dialogW, dialogH, false); + } + + int y = dialogY + innerPadding; + + renderer.drawCenteredText(UI_12_FONT_ID, y, title, true, EpdFontFamily::BOLD); + y += titleLineHeight; + + if (metrics.optionPopupTitleSeparator) { + const int sepY = y + metrics.optionPopupTitleGap / 2; + renderer.drawLine(dialogX + innerPadding, sepY, dialogX + dialogW - innerPadding, sepY, true); + } + + y += metrics.optionPopupTitleGap; + + const int itemRectX = dialogX + innerPadding; + const int itemRectW = dialogW - innerPadding * 2; + const int selectionRadius = metrics.optionPopupSelectionRadius; + + for (int i = 0; i < optionCount; i++) { + const int itemY = y + i * (rowHeight + itemSpacing); + const bool selected = (i == selectedIndex); + const char* labelText = options[i].c_str(); + + if (metrics.optionPopupDrawAllRows || selected) { + Color rowColor; + if (selected) { + rowColor = metrics.optionPopupSelectionLight ? Color::LightGray : Color::Black; + } else { + rowColor = Color::White; + } + if (selectionRadius > 0) { + renderer.fillRoundedRect(itemRectX, itemY, itemRectW, rowHeight, selectionRadius, rowColor); + } else { + renderer.fillRect(itemRectX, itemY, itemRectW, rowHeight, rowColor == Color::Black); + } + } + + const int textW = renderer.getTextWidth(optionFontId, labelText, optionStyle); + const int textY = itemY + (rowHeight - optionLineHeight) / 2; + const int textX = itemRectX + (itemRectW - textW) / 2; + // Unselected items: text is dark (invert=true means draw on white bg). + // Selected on dark bg: text must be white (invert=false). + // Selected on light bg: text stays dark (invert=true). + const bool invertText = selected ? metrics.optionPopupSelectionLight : true; + renderer.drawText(optionFontId, textX, textY, labelText, invertText, optionStyle); + } +} diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 39adb758..46d0bdaf 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -94,6 +94,19 @@ struct ThemeMetrics { bool popupProgressFillInverted; bool popupProgressOutlineInverted; + int optionPopupItemSpacing; + int optionPopupInnerPadding; + int optionPopupSelectionHPadding; + int optionPopupSelectionVPadding; + int optionPopupTitleGap; + bool optionPopupUseSmallFont; + bool optionPopupOptionFontBold; + int optionPopupSelectionRadius; + bool optionPopupSelectionLight; + bool optionPopupDrawAllRows; + int optionPopupDialogSideMargin; + bool optionPopupTitleSeparator; + int textFieldHorizontalPadding; int textFieldNormalThickness; int textFieldCursorThickness; @@ -167,6 +180,18 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .popupProgressClampPercent = false, .popupProgressFillInverted = true, .popupProgressOutlineInverted = true, + .optionPopupItemSpacing = 6, + .optionPopupInnerPadding = 16, + .optionPopupSelectionHPadding = 8, + .optionPopupSelectionVPadding = 4, + .optionPopupTitleGap = 10, + .optionPopupUseSmallFont = true, + .optionPopupOptionFontBold = true, + .optionPopupSelectionRadius = 0, + .optionPopupSelectionLight = false, + .optionPopupDrawAllRows = false, + .optionPopupDialogSideMargin = 20, + .optionPopupTitleSeparator = true, .textFieldHorizontalPadding = 6, .textFieldNormalThickness = 1, .textFieldCursorThickness = 3, @@ -207,6 +232,8 @@ class BaseTheme { const std::function& buttonLabel, const std::function& rowIcon) const; virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const; + virtual void drawOptionPopup(const GfxRenderer& renderer, const char* title, const std::vector& options, + int selectedIndex) const; virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const; void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount, std::string title, const int paddingBottom = 0, const int textYOffset = 0, diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index cec79ca5..15d94e66 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -65,6 +65,18 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .popupProgressClampPercent = false, .popupProgressFillInverted = false, .popupProgressOutlineInverted = false, + .optionPopupItemSpacing = 8, + .optionPopupInnerPadding = 20, + .optionPopupSelectionHPadding = 16, + .optionPopupSelectionVPadding = 12, + .optionPopupTitleGap = 16, + .optionPopupUseSmallFont = true, + .optionPopupOptionFontBold = false, + .optionPopupSelectionRadius = 6, + .optionPopupSelectionLight = true, + .optionPopupDrawAllRows = false, + .optionPopupDialogSideMargin = 20, + .optionPopupTitleSeparator = true, .textFieldHorizontalPadding = 6, .textFieldNormalThickness = 1, .textFieldCursorThickness = 3, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.h b/src/components/themes/roundedraff/RoundedRaffTheme.h index a5bfeeb9..316b8b05 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.h +++ b/src/components/themes/roundedraff/RoundedRaffTheme.h @@ -65,6 +65,18 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .popupProgressClampPercent = true, .popupProgressFillInverted = false, .popupProgressOutlineInverted = false, + .optionPopupItemSpacing = 6, + .optionPopupInnerPadding = 24, + .optionPopupSelectionHPadding = 20, + .optionPopupSelectionVPadding = 10, + .optionPopupTitleGap = 16, + .optionPopupUseSmallFont = false, + .optionPopupOptionFontBold = true, + .optionPopupSelectionRadius = 30, + .optionPopupSelectionLight = false, + .optionPopupDrawAllRows = true, + .optionPopupDialogSideMargin = 20, + .optionPopupTitleSeparator = true, .textFieldHorizontalPadding = 8, .textFieldNormalThickness = 2, .textFieldCursorThickness = 3,