feat: front button remapper (#664)
## Summary
* Custom remapper to create any variant of front button layout.
## Additional Context
* Included migration from previous frontlayout setting
* This will solve:
* https://github.com/crosspoint-reader/crosspoint-reader/issues/654
* https://github.com/crosspoint-reader/crosspoint-reader/issues/652
* https://github.com/crosspoint-reader/crosspoint-reader/issues/620
* https://github.com/crosspoint-reader/crosspoint-reader/issues/468
<img width="860" height="1147" alt="image"
src="https://github.com/user-attachments/assets/457356ed-7a7d-4e1c-8683-e187a1df47c0"
/>
---
### 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 >**_
This commit is contained in:
committed by
GitHub
parent
bf87a7dc60
commit
c49a819939
227
src/activities/settings/ButtonRemapActivity.cpp
Normal file
227
src/activities/settings/ButtonRemapActivity.cpp
Normal file
@@ -0,0 +1,227 @@
|
||||
#include "ButtonRemapActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
// UI steps correspond to logical roles in order: Back, Confirm, Left, Right.
|
||||
constexpr uint8_t kRoleCount = 4;
|
||||
// Marker used when a role has not been assigned yet.
|
||||
constexpr uint8_t kUnassigned = 0xFF;
|
||||
// Duration to show temporary error text when reassigning a button.
|
||||
constexpr unsigned long kErrorDisplayMs = 1500;
|
||||
} // namespace
|
||||
|
||||
void ButtonRemapActivity::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<ButtonRemapActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void ButtonRemapActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
// Start with all roles unassigned to avoid duplicate blocking.
|
||||
currentStep = 0;
|
||||
tempMapping[0] = kUnassigned;
|
||||
tempMapping[1] = kUnassigned;
|
||||
tempMapping[2] = kUnassigned;
|
||||
tempMapping[3] = kUnassigned;
|
||||
errorMessage.clear();
|
||||
errorUntil = 0;
|
||||
updateRequired = true;
|
||||
|
||||
xTaskCreate(&ButtonRemapActivity::taskTrampoline, "ButtonRemapTask", 4096, this, 1, &displayTaskHandle);
|
||||
}
|
||||
|
||||
void ButtonRemapActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
// Ensure display task is stopped outside of active rendering.
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
}
|
||||
|
||||
void ButtonRemapActivity::loop() {
|
||||
// Side buttons:
|
||||
// - Up: reset mapping to defaults and exit.
|
||||
// - Down: cancel without saving.
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
// Persist default mapping immediately so the user can recover quickly.
|
||||
SETTINGS.frontButtonBack = CrossPointSettings::FRONT_HW_BACK;
|
||||
SETTINGS.frontButtonConfirm = CrossPointSettings::FRONT_HW_CONFIRM;
|
||||
SETTINGS.frontButtonLeft = CrossPointSettings::FRONT_HW_LEFT;
|
||||
SETTINGS.frontButtonRight = CrossPointSettings::FRONT_HW_RIGHT;
|
||||
SETTINGS.saveToFile();
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
// Exit without changing settings.
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the UI to refresh before accepting another assignment.
|
||||
// This avoids rapid double-presses that can advance the step without a visible redraw.
|
||||
if (updateRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for a front button press to assign to the current role.
|
||||
const int pressedButton = mappedInput.getPressedFrontButton();
|
||||
if (pressedButton < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update temporary mapping and advance the remap step.
|
||||
// Only accept the press if this hardware button isn't already assigned elsewhere.
|
||||
if (!validateUnassigned(static_cast<uint8_t>(pressedButton))) {
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
tempMapping[currentStep] = static_cast<uint8_t>(pressedButton);
|
||||
currentStep++;
|
||||
|
||||
if (currentStep >= kRoleCount) {
|
||||
// All roles assigned; save to settings and exit.
|
||||
applyTempMapping();
|
||||
SETTINGS.saveToFile();
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
[[noreturn]] void ButtonRemapActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired) {
|
||||
// Ensure render calls are serialized with UI thread changes.
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
render();
|
||||
updateRequired = false;
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
|
||||
// Clear any temporary warning after its timeout.
|
||||
if (errorUntil > 0 && millis() > errorUntil) {
|
||||
errorMessage.clear();
|
||||
errorUntil = 0;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
vTaskDelay(50 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void ButtonRemapActivity::render() {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto labelForHardware = [&](uint8_t hardwareIndex) -> const char* {
|
||||
for (uint8_t i = 0; i < kRoleCount; i++) {
|
||||
if (tempMapping[i] == hardwareIndex) {
|
||||
return getRoleName(i);
|
||||
}
|
||||
}
|
||||
return "-";
|
||||
};
|
||||
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Remap Front Buttons", true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 40, "Press a front button for each role");
|
||||
|
||||
for (uint8_t i = 0; i < kRoleCount; i++) {
|
||||
const int y = 70 + i * 30;
|
||||
const bool isSelected = (i == currentStep);
|
||||
|
||||
// Highlight the role that is currently being assigned.
|
||||
if (isSelected) {
|
||||
renderer.fillRect(0, y - 2, pageWidth - 1, 30);
|
||||
}
|
||||
|
||||
const char* roleName = getRoleName(i);
|
||||
renderer.drawText(UI_10_FONT_ID, 20, y, roleName, !isSelected);
|
||||
|
||||
// Show currently assigned hardware button (or unassigned).
|
||||
const char* assigned = (tempMapping[i] == kUnassigned) ? "Unassigned" : getHardwareName(tempMapping[i]);
|
||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, assigned);
|
||||
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, y, assigned, !isSelected);
|
||||
}
|
||||
|
||||
// Temporary warning banner for duplicates.
|
||||
if (!errorMessage.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 210, errorMessage.c_str(), true);
|
||||
}
|
||||
|
||||
// Provide side button actions at the bottom of the screen (split across two lines).
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, 250, "Side button Up: Reset to default layout", true);
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, 280, "Side button Down: Cancel remapping", true);
|
||||
|
||||
// Live preview of logical labels under front buttons.
|
||||
// This mirrors the on-device front button order: Back, Confirm, Left, Right.
|
||||
GUI.drawButtonHints(renderer, labelForHardware(CrossPointSettings::FRONT_HW_BACK),
|
||||
labelForHardware(CrossPointSettings::FRONT_HW_CONFIRM),
|
||||
labelForHardware(CrossPointSettings::FRONT_HW_LEFT),
|
||||
labelForHardware(CrossPointSettings::FRONT_HW_RIGHT));
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void ButtonRemapActivity::applyTempMapping() {
|
||||
// Commit temporary mapping into settings (logical role -> hardware).
|
||||
SETTINGS.frontButtonBack = tempMapping[0];
|
||||
SETTINGS.frontButtonConfirm = tempMapping[1];
|
||||
SETTINGS.frontButtonLeft = tempMapping[2];
|
||||
SETTINGS.frontButtonRight = tempMapping[3];
|
||||
}
|
||||
|
||||
bool ButtonRemapActivity::validateUnassigned(const uint8_t pressedButton) {
|
||||
// Block reusing a hardware button already assigned to another role.
|
||||
for (uint8_t i = 0; i < kRoleCount; i++) {
|
||||
if (tempMapping[i] == pressedButton && i != currentStep) {
|
||||
errorMessage = "Already assigned";
|
||||
errorUntil = millis() + kErrorDisplayMs;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* ButtonRemapActivity::getRoleName(const uint8_t roleIndex) const {
|
||||
switch (roleIndex) {
|
||||
case 0:
|
||||
return "Back";
|
||||
case 1:
|
||||
return "Confirm";
|
||||
case 2:
|
||||
return "Left";
|
||||
case 3:
|
||||
default:
|
||||
return "Right";
|
||||
}
|
||||
}
|
||||
|
||||
const char* ButtonRemapActivity::getHardwareName(const uint8_t buttonIndex) const {
|
||||
switch (buttonIndex) {
|
||||
case CrossPointSettings::FRONT_HW_BACK:
|
||||
return "Back (1st button)";
|
||||
case CrossPointSettings::FRONT_HW_CONFIRM:
|
||||
return "Confirm (2nd button)";
|
||||
case CrossPointSettings::FRONT_HW_LEFT:
|
||||
return "Left (3rd button)";
|
||||
case CrossPointSettings::FRONT_HW_RIGHT:
|
||||
return "Right (4th button)";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
49
src/activities/settings/ButtonRemapActivity.h
Normal file
49
src/activities/settings/ButtonRemapActivity.h
Normal file
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class ButtonRemapActivity final : public Activity {
|
||||
public:
|
||||
explicit ButtonRemapActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onBack)
|
||||
: Activity("ButtonRemap", renderer, mappedInput), onBack(onBack) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
private:
|
||||
// Rendering task state.
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
bool updateRequired = false;
|
||||
|
||||
// Callback used to exit the remap flow back to the settings list.
|
||||
const std::function<void()> onBack;
|
||||
// Index of the logical role currently awaiting input.
|
||||
uint8_t currentStep = 0;
|
||||
// Temporary mapping from logical role -> hardware button index.
|
||||
uint8_t tempMapping[4] = {0xFF, 0xFF, 0xFF, 0xFF};
|
||||
// Error banner timing (used when reassigning duplicate buttons).
|
||||
unsigned long errorUntil = 0;
|
||||
std::string errorMessage;
|
||||
|
||||
// FreeRTOS task helpers.
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void render();
|
||||
|
||||
// Commit temporary mapping to settings.
|
||||
void applyTempMapping();
|
||||
// Returns false if a hardware button is already assigned to a different role.
|
||||
bool validateUnassigned(uint8_t pressedButton);
|
||||
// Labels for UI display.
|
||||
const char* getRoleName(uint8_t roleIndex) const;
|
||||
const char* getHardwareName(uint8_t buttonIndex) const;
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
|
||||
#include "ButtonRemapActivity.h"
|
||||
#include "CalibreSettingsActivity.h"
|
||||
#include "ClearCacheActivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
@@ -47,9 +48,8 @@ const SettingInfo readerSettings[readerSettingsCount] = {
|
||||
|
||||
constexpr int controlsSettingsCount = 4;
|
||||
const SettingInfo controlsSettings[controlsSettingsCount] = {
|
||||
SettingInfo::Enum(
|
||||
"Front Button Layout", &CrossPointSettings::frontButtonLayout,
|
||||
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}),
|
||||
// Launches the remap wizard for front buttons.
|
||||
SettingInfo::Action("Remap Front Buttons"),
|
||||
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
|
||||
{"Prev, Next", "Next, Prev"}),
|
||||
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
|
||||
@@ -201,7 +201,15 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
|
||||
}
|
||||
} else if (setting.type == SettingType::ACTION) {
|
||||
if (strcmp(setting.name, "KOReader Sync") == 0) {
|
||||
if (strcmp(setting.name, "Remap Front Buttons") == 0) {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
enterNewActivity(new ButtonRemapActivity(renderer, mappedInput, [this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
} else if (strcmp(setting.name, "KOReader Sync") == 0) {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] {
|
||||
|
||||
Reference in New Issue
Block a user