feat: add OPDS server reordering with sortOrder field and numeric stepper

Servers are sorted by a persistent sortOrder field (ties broken
alphabetically). On-device editing uses a new NumericStepperActivity
with side buttons for ±1 and face buttons for ±10. The web UI gets
up/down arrow buttons and a POST /api/opds/reorder endpoint.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-02 14:35:36 -05:00
parent 3628d8eb37
commit f955cf2fb4
18 changed files with 328 additions and 25 deletions

View File

@@ -4,18 +4,20 @@
#include <I18n.h>
#include <cstring>
#include <string>
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "activities/util/DirectoryPickerActivity.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "activities/util/NumericStepperActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
// Editable fields: Name, URL, Username, Password, Download Path.
// Editable fields: Position, Name, URL, Username, Password, Download Path.
// Existing servers also show a Delete option (BASE_ITEMS + 1).
constexpr int BASE_ITEMS = 5;
constexpr int BASE_ITEMS = 6;
} // namespace
int OpdsSettingsActivity::getMenuItemCount() const {
@@ -75,17 +77,38 @@ void OpdsSettingsActivity::loop() {
void OpdsSettingsActivity::saveServer() {
if (isNewServer) {
OPDS_STORE.addServer(editServer);
// After the first field is saved, promote to an existing server so
// subsequent field edits update in-place rather than creating duplicates.
isNewServer = false;
serverIndex = static_cast<int>(OPDS_STORE.getCount()) - 1;
} else {
OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer);
}
// Re-locate our server after add/update may have re-sorted the vector
const auto& servers = OPDS_STORE.getServers();
for (size_t i = 0; i < servers.size(); i++) {
if (servers[i].url == editServer.url && servers[i].name == editServer.name) {
serverIndex = static_cast<int>(i);
break;
}
}
}
void OpdsSettingsActivity::handleSelection() {
if (selectedIndex == 0) {
// Position (sort order)
exitActivity();
enterNewActivity(new NumericStepperActivity(
renderer, mappedInput, tr(STR_POSITION), editServer.sortOrder, 1, 99,
[this](int value) {
editServer.sortOrder = value;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 1) {
// Server Name
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
@@ -100,7 +123,7 @@ void OpdsSettingsActivity::handleSelection() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 1) {
} else if (selectedIndex == 2) {
// Server URL
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
@@ -115,7 +138,7 @@ void OpdsSettingsActivity::handleSelection() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 2) {
} else if (selectedIndex == 3) {
// Username
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
@@ -130,7 +153,7 @@ void OpdsSettingsActivity::handleSelection() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 3) {
} else if (selectedIndex == 4) {
// Password
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
@@ -145,7 +168,7 @@ void OpdsSettingsActivity::handleSelection() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 4) {
} else if (selectedIndex == 5) {
// Download Path
exitActivity();
enterNewActivity(new DirectoryPickerActivity(
@@ -162,7 +185,7 @@ void OpdsSettingsActivity::handleSelection() {
requestUpdate();
},
editServer.downloadPath));
} else if (selectedIndex == 5 && !isNewServer) {
} else if (selectedIndex == 6 && !isNewServer) {
// Delete server
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
onBack();
@@ -184,8 +207,8 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
const int menuItems = getMenuItemCount();
const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME,
StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH};
const StrId fieldNames[] = {StrId::STR_POSITION, StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL,
StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH};
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
@@ -198,14 +221,16 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
nullptr, nullptr,
[this](int index) {
if (index == 0) {
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
return std::to_string(editServer.sortOrder);
} else if (index == 1) {
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
} else if (index == 2) {
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
} else if (index == 3) {
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
} else if (index == 4) {
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
} else if (index == 5) {
return editServer.downloadPath;
}
return std::string("");

View File

@@ -0,0 +1,99 @@
#include "NumericStepperActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
#include <cstdio>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
void NumericStepperActivity::onEnter() {
Activity::onEnter();
requestUpdate();
}
void NumericStepperActivity::onExit() { Activity::onExit(); }
void NumericStepperActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
if (onCancel) onCancel();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (onComplete) onComplete(value);
return;
}
// Side buttons: ±1
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
if (value < maxValue) {
value++;
requestUpdate();
}
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (value > minValue) {
value--;
requestUpdate();
}
return;
}
// Front face buttons: ±10
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
value = std::min(value + 10, maxValue);
requestUpdate();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
value = std::max(value - 10, minValue);
requestUpdate();
return;
}
}
void NumericStepperActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
const auto& metrics = UITheme::getInstance().getMetrics();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, title.c_str());
char valueStr[16];
snprintf(valueStr, sizeof(valueStr), "%d", value);
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, valueStr);
const int startX = (pageWidth - textWidth) / 2;
const int valueY = metrics.topPadding + metrics.headerHeight + 60;
constexpr int highlightPad = 10;
renderer.fillRoundedRect(startX - highlightPad, valueY - 4, textWidth + highlightPad * 2, lineHeight12 + 8, 6,
Color::LightGray);
renderer.drawText(UI_12_FONT_ID, startX, valueY, valueStr, true);
const int arrowX = pageWidth / 2;
const int arrowUpY = valueY - 20;
const int arrowDownY = valueY + lineHeight12 + 12;
constexpr int arrowSize = 6;
for (int row = 0; row < arrowSize; row++) {
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
}
for (int row = 0; row < arrowSize; row++) {
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), "-10", "+10");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
GUI.drawSideButtonHints(renderer, "+1", "-1");
renderer.displayBuffer();
}

View File

@@ -0,0 +1,42 @@
#pragma once
#include <functional>
#include <string>
#include <utility>
#include "activities/Activity.h"
/**
* Reusable numeric stepper for integer value entry.
* Side buttons (Up/Down) step by 1, face buttons (Left/Right) step by 10.
* Value is clamped within [min, max].
*/
class NumericStepperActivity final : public Activity {
public:
using OnCompleteCallback = std::function<void(int)>;
using OnCancelCallback = std::function<void()>;
explicit NumericStepperActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title,
int initialValue, int minValue, int maxValue, OnCompleteCallback onComplete,
OnCancelCallback onCancel)
: Activity("NumericStepper", renderer, mappedInput),
title(std::move(title)),
value(initialValue),
minValue(minValue),
maxValue(maxValue),
onComplete(std::move(onComplete)),
onCancel(std::move(onCancel)) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(Activity::RenderLock&&) override;
private:
std::string title;
int value;
int minValue;
int maxValue;
OnCompleteCallback onComplete;
OnCancelCallback onCancel;
};