Files
MeshCore-Solo/examples/companion_radio/ui-new/RadioPresetPicker.h
T
Jakub 691e2ab506 fix(ui): confirm destructive actions, retire last Hold-Enter-cancel, de-dup labels
Continuing the consistency review: sweep for the same three defect shapes
elsewhere in ui-new/ (own read pass plus two parallel research agents),
verified against source before acting.

Three destructive actions fired on a single Enter with no way back, unlike
contact-delete's existing confirm-defaulted-to-Cancel popup: Trail's "Reset
trail" (wipes the whole recorded route, no undo short of a prior manual
Save -- reuses Trail's own multi-level menu machinery, alongside its
GPS-off confirm), Messages' channel Delete, and RadioPresetPicker's saved-
preset delete (shared by Settings > Radio and Tools > Repeater, so one fix
covers both). All three now confirm the same way, defaulting to Cancel.

KeyboardWidget was the one place Hold-Enter still doubled as Cancel: Shift,
Backspace and a Latin letter's accent popup already have real, kept
meanings under a hold, but every other special-row cell (Space, OK/Done,
the {} placeholder) fell through to a bare CANCELLED, closing the keyboard
exactly like the real Cancel key. Now a no-op there too, matching the "only
Back closes it" rule already applied to popups and screens.

MessagesScreen defined the same two label arrays (Notif states, melody
slots) four times over, once per context-menu handler. Hoisted to one
pair of static class members -- constexpr wasn't enough to get the linker
to emit them on this toolchain, so they follow the same declare-in-class/
define-out-of-class shape NearbyScreen::FILTER_LABELS already uses.

Alert text: "Advert sent!"/"Advert failed.." and "Sent!" were the only
toasts anywhere with trailing punctuation; normalized to the plain style
every other confirmation uses. Unpinning from the Favourites Dial reported
the freed slot number from the Messages screens but not from Nodes or the
dial's own tile menu; now consistent everywhere pinning already was.

DiagnosticsScreen's Live/System/Font tab renderers hand-rolled the same
scroll-clamp/loop/indicator skeleton drawList() already bundles; switched
both to drawList (passing the screen's own _scroll as its `sel` too, since
neither tab has a row cursor -- makes drawList's internal clamp a no-op and
leaves clampScroll() as the only thing bounding it, unchanged). Pure
internal tidy, no behavior change.
2026-08-31 19:52:43 +02:00

184 lines
7.1 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
#include <string.h>
#include "PopupMenu.h"
#include "../RadioPresets.h"
#include "../NodePrefs.h"
// Shared "radio preset" picker used by both Settings Radio and Tools
// Repeater. Both edit the same 4 user preset slots in NodePrefs and build the
// same popup layout; they differ only in which freq/bw/sf/cr fields they target
// (the companion's own params vs. the dedicated repeater profile) and in what
// runs when a value is applied. This struct owns the popup + save/delete state
// and the lookup/save logic; the owning screen passes a Target pointing at its
// fields and handles apply()/dirty/keyboard/alert on the Result it returns.
//
// Popup layout (built by open()):
// [0] = "+ Save current..."
// [1 .. RADIO_PRESET_COUNT] = built-in presets
// [.. + user_count] = saved user presets (empty slots skipped)
// [last, if user_count > 0] = "- Delete preset..."
struct RadioPresetPicker {
// The four radio fields this picker reads and writes.
struct Target {
float* freq;
float* bw;
uint8_t* sf;
uint8_t* cr;
};
// What the owning screen should do after a SELECTED item is handled.
enum Result {
NONE, // nothing to do (sub-menu opened, or a no-op row)
START_SAVE, // open the keyboard to name a new preset, then call save()
APPLIED, // target fields changed — apply to the radio + mark dirty
DELETED // a saved preset was removed — mark dirty + alert
};
PopupMenu menu;
uint8_t user_slot[NodePrefs::USER_RADIO_PRESET_MAX]; // popup row → NodePrefs slot
int user_count = 0;
bool saving = false; // keyboard is open to name a new preset
bool deleting = false; // menu is showing the delete sub-list
int confirm_slot = -1; // >=0 = confirming deletion of this NodePrefs slot
static bool matches(const Target& t, float freq, float bw, uint8_t sf, uint8_t cr) {
return radioParamsMatchPreset(*t.freq, *t.bw, *t.sf, *t.cr, freq, bw, sf, cr);
}
// Display name for the target: a built-in preset, a saved user preset, or
// "Custom" if the params don't exactly match any of them.
const char* currentName(NodePrefs* p, const Target& t) const {
if (!p) return "Custom";
for (int i = 0; i < RADIO_PRESET_COUNT; i++) {
const RadioPreset& r = RADIO_PRESETS[i];
if (matches(t, r.freq, r.bw, r.sf, r.cr)) return r.name;
}
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
const auto& u = p->user_radio_presets[i];
if (u.name[0] && matches(t, u.freq, u.bw, u.sf, u.cr)) return u.name;
}
return "Custom";
}
// Position of the target within the popup list built by open(), or -1
// ("Custom") if nothing matches.
int currentListIndex(NodePrefs* p, const Target& t) const {
if (!p) return -1;
for (int i = 0; i < RADIO_PRESET_COUNT; i++) {
const RadioPreset& r = RADIO_PRESETS[i];
if (matches(t, r.freq, r.bw, r.sf, r.cr)) return i + 1;
}
int pos = RADIO_PRESET_COUNT + 1;
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
const auto& u = p->user_radio_presets[i];
if (!u.name[0]) continue;
if (matches(t, u.freq, u.bw, u.sf, u.cr)) return pos;
pos++;
}
return -1;
}
// Save the target's params as a named user preset: overwrite a slot with the
// same name if one exists, else the first empty slot, else slot 0 (oldest).
// Returns true if anything was written (caller alerts + marks dirty).
bool save(NodePrefs* p, const char* name, const Target& t) {
if (!p || !name || !name[0]) return false;
int slot = -1;
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++)
if (strcmp(p->user_radio_presets[i].name, name) == 0) { slot = i; break; }
if (slot < 0)
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++)
if (!p->user_radio_presets[i].name[0]) { slot = i; break; }
if (slot < 0) slot = 0;
NodePrefs::UserRadioPreset& u = p->user_radio_presets[slot];
strncpy(u.name, name, sizeof(u.name) - 1);
u.name[sizeof(u.name) - 1] = '\0';
u.freq = *t.freq; u.bw = *t.bw; u.sf = *t.sf; u.cr = *t.cr;
return true;
}
void open(NodePrefs* p, const Target& t, const char* title) {
deleting = false;
menu.begin(title, 6);
menu.addItem("+ Save current...");
for (int i = 0; i < RADIO_PRESET_COUNT; i++) menu.addItem(RADIO_PRESETS[i].name);
user_count = 0;
if (p) {
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
if (!p->user_radio_presets[i].name[0]) continue;
menu.addItem(p->user_radio_presets[i].name);
user_slot[user_count++] = (uint8_t)i;
}
}
if (user_count > 0) menu.addItem("- Delete preset...");
int idx = currentListIndex(p, t);
menu.setSelected(idx >= 0 ? idx : 0);
}
// Reuses `menu` for a second-level list of just the saved user presets (their
// slot mapping in user_slot is still the one open() built, since this is only
// reached from within that same popup).
void openDelete(NodePrefs* p) {
menu.begin("Delete Preset", 6);
for (int i = 0; i < user_count; i++)
menu.addItem(p->user_radio_presets[user_slot[i]].name);
deleting = true;
}
// Third level, reached by picking a name off the delete sub-list: a plain
// Delete/Cancel confirm, defaulting to Cancel like every other destructive
// action's popup (see NearbyScreen's contact-delete confirm). Whichever row
// is picked, onSelected() below is terminal -- the whole picker closes,
// same as it already does after a built-in/user preset pick.
void openConfirm(uint8_t slot) {
menu.begin("Delete preset?", 2);
menu.addItem("Delete");
menu.addItem("Cancel");
menu.setSelected(1);
confirm_slot = slot;
deleting = false;
}
// Handle the index the popup reports as SELECTED. Mutates target fields on a
// built-in/user pick; opens the delete confirm from the delete sub-list, or
// resolves that confirm if it's the level currently showing. See Result.
Result onSelected(int idx, NodePrefs* p, const Target& t) {
if (!p) return NONE;
if (confirm_slot >= 0) {
Result r = NONE;
if (idx == 0) { // "Delete"
p->user_radio_presets[confirm_slot].name[0] = '\0';
r = DELETED;
}
confirm_slot = -1;
return r;
}
if (deleting) {
if (idx >= 0 && idx < user_count) openConfirm(user_slot[idx]);
deleting = false;
return NONE;
}
const int builtin_base = 1;
const int user_base = builtin_base + RADIO_PRESET_COUNT;
const int delete_idx = user_base + user_count;
if (idx == 0) {
saving = true;
return START_SAVE;
}
if (idx >= builtin_base && idx < user_base) {
const RadioPreset& r = RADIO_PRESETS[idx - builtin_base];
*t.freq = r.freq; *t.bw = r.bw; *t.sf = r.sf; *t.cr = r.cr;
return APPLIED;
}
if (idx >= user_base && idx < delete_idx) {
const NodePrefs::UserRadioPreset& u = p->user_radio_presets[user_slot[idx - user_base]];
*t.freq = u.freq; *t.bw = u.bw; *t.sf = u.sf; *t.cr = u.cr;
return APPLIED;
}
if (user_count > 0 && idx == delete_idx) {
openDelete(p);
}
return NONE;
}
};