feat(companion): shared named-scope list, per-channel/repeater popup pickers

Replace the single device-wide Scope text field with a small named list
(wildcard "*" fixed first, plus a movable default): each channel picks one
scope of its own (matching the app's per-channel region picker, surfaced in
the channel-history title), and the repeater's Extra scopes multi-selects
from the same list instead of comma-typed names. Both pickers are popups
(PopupMenu gains addCheckItem()/a fillable-square checkbox glyph) over the
existing screen rather than a full-screen takeover.

Also fixes a stray-bits bug where a pre-existing prefs file's own sentinel
tail could read into the new repeat_extra_scope_mask/ch_scope_idx fields and
silently reappear as a real pick later once the scope list grew into that
range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-12 00:05:40 +02:00
co-authored by Claude Sonnet 5
parent 7533c70ee2
commit 89c02eea27
13 changed files with 592 additions and 90 deletions
+84
View File
@@ -618,6 +618,15 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(&_prefs.msg_wake_screen_off, sizeof(_prefs.msg_wake_screen_off));
if (_prefs.msg_wake_screen_off > 1) _prefs.msg_wake_screen_off = 0;
// → 0xC0DE002B: append repeat_extra_scope_mask + ch_scope_idx. A pre-0x2B
// file has stray sentinel bytes from that file's own tail sitting here --
// read as-is for now, zeroed below once the sentinel mismatch confirms this
// really is a pre-0x2B file (can't range-clamp a mask/index here: every bit
// or byte value is technically "valid", so garbage can't be told apart from
// a real pick until we know which schema version wrote it).
rd(&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask));
rd(_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx));
// Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() already zero-inits any fields not present,
// so we just log it — next savePrefs writes the current sentinel.
@@ -671,6 +680,19 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
// → 0xC0DE000D: append user_radio_presets. No clamping needed — rd() already
// zero-inits it on a pre-0x0D file, and name[0]=='\0' is exactly the "empty
// slot" sentinel the UI already expects.
// 0xC0DE002A → 0xC0DE002B: repeat_extra_scope_mask + ch_scope_idx appended.
// Unlike the fields above, these can't be left with whatever stray bytes
// rd() picked up from a pre-0x2B file's own sentinel tail: every bit/byte
// value is "valid" (any mask or index could be a real pick), so garbage
// here isn't caught by a range clamp -- it just silently masquerades as a
// real one, and can even reactivate later once the scope list grows long
// enough to reach an index that used to be out of range. Zero both
// outright on this one transition; a fresh scope list is empty anyway, so
// there's nothing genuine to lose.
if (sentinel < 0xC0DE002B) {
_prefs.repeat_extra_scope_mask = 0;
memset(_prefs.ch_scope_idx, 0, sizeof(_prefs.ch_scope_idx));
}
}
file.close();
@@ -838,6 +860,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)_prefs.favourite_kinds, sizeof(_prefs.favourite_kinds));
file.write((uint8_t *)&_prefs.fav_sort_off, sizeof(_prefs.fav_sort_off));
file.write((uint8_t *)&_prefs.msg_wake_screen_off, sizeof(_prefs.msg_wake_screen_off));
file.write((uint8_t *)&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask));
file.write((uint8_t *)_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
// the one we check: once the flash fills, writes return 0, so a good
@@ -1083,6 +1107,66 @@ void DataStore::saveChannels(DataStoreHost* host) {
}
}
bool DataStore::loadScopeList(ScopeList& list, const NodePrefs& prefs) {
File file = openRead("/scopes1");
if (file) {
uint8_t hdr[2];
bool success = (file.read(hdr, 2) == 2);
uint8_t count = success ? hdr[1] : 0;
if (count > ScopeList::MAX_SCOPE_ENTRIES) count = 0; // corrupt header -- start empty rather than overrun entries[]
uint8_t loaded = 0;
for (uint8_t i = 0; i < count; i++) {
ScopeEntry e;
bool ok = (file.read((uint8_t *)e.name, sizeof(e.name)) == sizeof(e.name));
ok = ok && (file.read(e.key, sizeof(e.key)) == sizeof(e.key));
if (!ok) break; // truncated file -- keep whatever loaded fine so far
e.name[sizeof(e.name) - 1] = '\0';
list.entries[loaded++] = e;
}
file.close();
list.count = loaded;
list.default_idx = list.clamp(hdr[0]);
return true;
}
// No /scopes1 yet -- one-time migration of an existing single
// default_scope_name/key (Settings > Radio > Scope, pre-list) into list
// entry 1, so an already-configured device keeps sending under the same
// scope after upgrading. A never-configured device just stays at the
// default-constructed ScopeList (empty, default_idx 0 == "*").
list.count = 0;
list.default_idx = 0;
if (prefs.default_scope_name[0] != '\0') {
ScopeEntry& e = list.entries[0];
StrHelper::strncpy(e.name, prefs.default_scope_name, sizeof(e.name));
memcpy(e.key, prefs.default_scope_key, sizeof(e.key)); // already-derived key, no need to re-derive
list.count = 1;
list.default_idx = 1;
}
saveScopeList(list); // write /scopes1 so this migration runs only once
return true;
}
void DataStore::saveScopeList(const ScopeList& list) {
File file = ::openWrite(_fs, "/scopes1.tmp");
if (!file) return;
uint8_t hdr[2] = { list.default_idx, list.count };
bool ok = (file.write(hdr, 2) == 2);
for (uint8_t i = 0; ok && i < list.count; i++) {
ok = (file.write((uint8_t *)list.entries[i].name, sizeof(list.entries[i].name)) == sizeof(list.entries[i].name));
ok = ok && (file.write(list.entries[i].key, sizeof(list.entries[i].key)) == sizeof(list.entries[i].key));
}
file.close();
if (ok) {
commitTempFile(_fs, "/scopes1.tmp", "/scopes1");
} else {
_fs->remove("/scopes1.tmp"); // keep the previous good /scopes1
}
}
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#define MAX_ADVERT_PKT_LEN (2 + 32 + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE)
+10
View File
@@ -4,6 +4,7 @@
#include <helpers/ContactInfo.h>
#include <helpers/ChannelDetails.h>
#include "NodePrefs.h"
#include "ScopeList.h"
class DataStoreHost {
public:
@@ -45,6 +46,15 @@ public:
// deleted slot is simply absent from the file, not written as empty).
bool loadChannels(DataStoreHost* host);
void saveChannels(DataStoreHost* host);
// /scopes1: the shared named-scope list (see ScopeList.h). `prefs` is only
// read, for a one-time migration of a pre-existing single
// default_scope_name/key into list entry 1 -- the file is authoritative
// once it exists. Returns true if the file existed or the migration ran
// (i.e. `list` reflects real prior configuration); false only means a
// genuinely fresh, unconfigured device (list left at its default: empty,
// default_idx 0 == "*").
bool loadScopeList(ScopeList& list, const NodePrefs& prefs);
void saveScopeList(const ScopeList& list);
void migrateToSecondaryFS();
uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]);
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len);
+89 -32
View File
@@ -677,6 +677,9 @@ bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
}
void MyMesh::setPrimaryScope(const char* name) {
// Keep the legacy fields in sync too -- inert for rebuildRepeatScopes()
// once /scopes1 exists, but still what a pre-list save file round-trips,
// and cheap to maintain.
strncpy(_prefs.default_scope_name, name, sizeof(_prefs.default_scope_name) - 1);
_prefs.default_scope_name[sizeof(_prefs.default_scope_name) - 1] = '\0';
if (_prefs.default_scope_name[0] == '\0') {
@@ -689,37 +692,85 @@ void MyMesh::setPrimaryScope(const char* name) {
temp.getAutoKeyFor(0, hashtag, key);
memcpy(_prefs.default_scope_key, key.key, sizeof(key.key));
}
if (_prefs.default_scope_name[0] == '\0') {
_scope_list.default_idx = 0; // "*"
} else {
// Find-or-create a matching named entry (e.g. the app driving this
// remotely via CMD_SET_DEFAULT_FLOOD_SCOPE), then mark it default.
uint8_t idx = 0;
for (uint8_t i = 0; i < _scope_list.count; i++) {
if (strcmp(_scope_list.entries[i].name, _prefs.default_scope_name) == 0) { idx = i + 1; break; }
}
if (idx == 0) idx = _scope_list.add(_prefs.default_scope_name);
_scope_list.default_idx = idx; // still 0 ("*") if the list was full
}
if (_store) _store->saveScopeList(_scope_list);
rebuildRepeatScopes();
}
uint8_t MyMesh::addScope(const char* name) {
uint8_t idx = _scope_list.add(name);
if (idx && _store) _store->saveScopeList(_scope_list);
return idx;
}
void MyMesh::renameScope(uint8_t idx, const char* name) {
if (idx < 1 || idx > _scope_list.count || !name || !name[0]) return;
ScopeEntry& e = _scope_list.entries[idx - 1];
StrHelper::strncpy(e.name, name, sizeof(e.name));
ScopeList::deriveKey(e.name, e.key);
if (_store) _store->saveScopeList(_scope_list);
rebuildRepeatScopes(); // this entry's key may be repeat_scopes[]'s default or an extra slot
}
void MyMesh::removeScope(uint8_t idx) {
if (idx < 1 || idx > _scope_list.count) return;
_scope_list.remove(idx);
// repeat_extra_scope_mask bit (i) tracks list index (i+1) -- shift down the
// same way ScopeList::remove() shifted entries[], dropping the removed bit.
uint16_t old_mask = _prefs.repeat_extra_scope_mask, new_mask = 0;
for (uint8_t i = 0; i < ScopeList::MAX_SCOPE_ENTRIES; i++) { // one bit per possible list entry, not per MAX_REPEAT_SCOPES active slot
if (!(old_mask & (1u << i))) continue;
uint8_t list_idx = i + 1;
if (list_idx < idx) new_mask |= (1u << i);
else if (list_idx > idx) new_mask |= (1u << (i - 1));
// list_idx == idx: the removed one, dropped
}
_prefs.repeat_extra_scope_mask = new_mask;
// Any channel pointed at the removed entry (or shifted ones) needs the same
// fix-up ScopeList::remove() applied to default_idx.
for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) {
uint8_t ci = _prefs.ch_scope_idx[i];
if (ci == idx) _prefs.ch_scope_idx[i] = 0;
else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1;
}
if (_store) _store->saveScopeList(_scope_list);
rebuildRepeatScopes();
}
void MyMesh::setDefaultScope(uint8_t idx) {
_scope_list.default_idx = _scope_list.clamp(idx);
if (_store) _store->saveScopeList(_scope_list);
rebuildRepeatScopes();
}
void MyMesh::setChannelScope(uint8_t channel_idx, uint8_t idx) {
if (channel_idx >= NodePrefs::MAX_SCOPED_CHANNELS) return;
_prefs.ch_scope_idx[channel_idx] = _scope_list.clamp(idx);
}
void MyMesh::rebuildRepeatScopes() {
repeat_scope_count = 0;
TransportKey primary;
memcpy(primary.key, _prefs.default_scope_key, sizeof(primary.key));
TransportKey primary = _scope_list.key(_scope_list.default_idx);
if (!primary.isNull()) repeat_scopes[repeat_scope_count++] = primary;
TransportKeyStore temp;
char names[sizeof(_prefs.repeat_extra_scopes)];
strncpy(names, _prefs.repeat_extra_scopes, sizeof(names));
names[sizeof(names) - 1] = '\0';
char* tok = strtok(names, ",");
while (tok != NULL && repeat_scope_count < MAX_REPEAT_SCOPES) {
while (*tok == ' ') tok++; // trim leading spaces
char* end = tok + strlen(tok);
while (end > tok && end[-1] == ' ') *(--end) = '\0'; // trim trailing spaces
if (*tok != '\0') {
char hashtag[1 + sizeof(_prefs.repeat_extra_scopes)];
snprintf(hashtag, sizeof(hashtag), "#%s", tok);
// Distinct id per scope: getAutoKeyFor() keys its cache on the id ALONE
// and ignores the name on a hit, so reusing one id here would hand every
// scope after the first the first one's key.
temp.getAutoKeyFor(repeat_scope_count, hashtag, repeat_scopes[repeat_scope_count]);
repeat_scope_count++;
}
tok = strtok(NULL, ",");
for (uint8_t i = 0; i < _scope_list.count && repeat_scope_count < MAX_REPEAT_SCOPES; i++) {
if (!(_prefs.repeat_extra_scope_mask & (1u << i))) continue;
uint8_t list_idx = i + 1;
TransportKey k = _scope_list.key(list_idx);
if (!k.isNull()) repeat_scopes[repeat_scope_count++] = k;
}
}
@@ -739,25 +790,30 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
if (send_unscoped) {
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped
} else {
TransportKey default_scope;
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
TransportKey default_scope = _scope_list.key(_scope_list.default_idx);
auto scope = send_scope.isNull() ? &default_scope : &send_scope;
sendFloodScoped(*scope, pkt, delay_millis);
}
}
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
// TODO: have per-channel send_scope
if (apcActive()) apcTrackFloodSend(pkt); // listen for a repeater echo to drive APC (channels have no ACK)
trackRelaySend(pkt); // and for the UI "relayed" marker
if (send_unscoped) {
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped
} else if (!send_scope.isNull()) {
// App-driven per-send override (CMD_SET_FLOOD_SCOPE_KEY) still wins over
// this channel's own on-device pick, same precedence DMs already have.
sendFloodScoped(send_scope, pkt, delay_millis);
} else {
TransportKey default_scope;
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
auto scope = send_scope.isNull() ? &default_scope : &send_scope;
sendFloodScoped(*scope, pkt, delay_millis);
// Resolve THIS channel's own scope-list pick (Messages > channel context
// menu > Scope:), falling back to the list's default if this channel has
// none of its own or can't be identified (e.g. a bot/room send path that
// doesn't go through a slot in channels[]).
int channel_idx = findChannelIdx(channel);
uint8_t list_idx = (channel_idx >= 0 && channel_idx < NodePrefs::MAX_SCOPED_CHANNELS)
? _prefs.ch_scope_idx[channel_idx] : _scope_list.default_idx;
TransportKey scope = _scope_list.key(list_idx);
sendFloodScoped(scope, pkt, delay_millis);
}
}
@@ -1763,6 +1819,7 @@ void MyMesh::begin(bool has_display) {
// load persisted prefs
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
_store->loadScopeList(_scope_list, _prefs);
rebuildRepeatScopes();
// sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain
+46 -12
View File
@@ -42,6 +42,7 @@ class UITask;
#include "DataStore.h"
#include "NodePrefs.h"
#include "ScopeList.h"
#include <RTClib.h>
#include <helpers/ArduinoHelpers.h>
@@ -350,19 +351,45 @@ public:
// the repeater toggle / network / profile changes.
void applyRepeaterRadio();
// Sets this device's own scope (Settings > Radio > Scope) from a single
// typed region name, deriving default_scope_key the same "#name" -> SHA256
// way as DEFAULT_FLOOD_SCOPE_NAME (see begin()). Empty name clears the
// scope. Governs what scope the companion's own messages send under, and
// (as scope[0]) what repeat_scope_only accepts. Calls rebuildRepeatScopes().
// Sets this device's own default scope (Settings > Radio > Scope, and the
// app's CMD_SET_DEFAULT_FLOOD_SCOPE) from a single typed region name.
// Keeps default_scope_name/default_scope_key in sync (legacy fields, inert
// once /scopes1 exists -- see ScopeList.h) *and* finds-or-creates a
// matching entry in the shared scope list, marking it default -- so a
// scope set remotely by the app shows up as a real, named entry on the
// device's own list too, not just in the two legacy fields. Empty name
// clears the default back to list index 0 ("*"). Calls
// rebuildRepeatScopes() and persists the list.
void setPrimaryScope(const char* name);
// Rebuilds repeat_scopes[]/repeat_scope_count from default_scope_key (slot 0,
// if configured) plus the comma-separated repeat_extra_scopes (Tools >
// Repeater > Extra scopes). Call after loading prefs at boot and whenever
// either scope setting is edited.
// Rebuilds repeat_scopes[]/repeat_scope_count from the scope list's current
// default entry (slot 0) plus repeat_extra_scope_mask (Tools > Repeater >
// Extra scopes -- a toggle over the same list). Call after loading prefs +
// the scope list at boot and whenever either scope setting is edited.
void rebuildRepeatScopes();
// The shared named-scope list (Settings > Radio > Scope). Read-only outside
// MyMesh -- edits go through setPrimaryScope()/addScope()/setChannelScope()/
// setDefaultScope() so repeat_scopes[]/persistence stay in sync.
const ScopeList& scopeList() const { return _scope_list; }
// Adds a new named entry (see ScopeList::add()), persists the list, and
// returns its list index (0 if the name's empty or the list's full).
uint8_t addScope(const char* name);
// Renames list index idx (1..count; a no-op for 0/"*" or an empty name),
// re-deriving its key (a scope's key is purely a hash of its name) and
// persisting the list.
void renameScope(uint8_t idx, const char* name);
// Removes list index idx (1..count; a no-op for 0/"*"), persists the list,
// fixes up repeat_extra_scope_mask, and clears any channel's ch_scope_idx
// that pointed at it (shift-and-clamp, matching ScopeList::remove()).
void removeScope(uint8_t idx);
// Marks list index idx as the default (used for DMs and any channel/
// repeater slot without its own pick). Persists the list and rebuilds
// repeat_scopes[].
void setDefaultScope(uint8_t idx);
// Sets channel_idx's own scope-list pick (0 = "*"). Persists prefs.
void setChannelScope(uint8_t channel_idx, uint8_t idx);
bool isAckPending(uint32_t expected_ack) const {
if (expected_ack == 0) return false; // 0 marks an empty/cleared slot, not a real ACK
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)
@@ -560,10 +587,17 @@ private:
TransportKey send_scope;
// The shared named-scope list backing Settings > Radio > Scope, the
// channel context menu's Scope: row, and Tools > Repeater > Extra scopes.
// Loaded once at boot (see begin()) via DataStore::loadScopeList(), kept in
// sync with /scopes1 by every mutator above.
ScopeList _scope_list;
// Runtime-only (not persisted) cache of scopes accepted by repeat_scope_only:
// slot 0 is default_scope_key (if configured), the rest are derived from the
// comma-separated repeat_extra_scopes. Rebuilt by rebuildRepeatScopes() (see
// the public section below) whenever either scope setting changes.
// slot 0 is the scope list's current default entry (if not "*"), the rest
// are derived from repeat_extra_scope_mask's set bits. Rebuilt by
// rebuildRepeatScopes() (see the public section below) whenever either
// scope setting changes.
static const uint8_t MAX_REPEAT_SCOPES = 4;
TransportKey repeat_scopes[MAX_REPEAT_SCOPES];
uint8_t repeat_scope_count;
+27 -2
View File
@@ -149,7 +149,17 @@ struct NodePrefs { // persisted to file
// Settings > Radio > Scope (comma-separated, e.g. "eu,de") — see
// MyMesh::rebuildRepeatScopes(). Relay-only: never affects what scope the
// companion's own messages send under, only what repeat_scope_only accepts.
// Superseded by repeat_extra_scope_mask below (a scope-list toggle,
// replacing free-typed names); left allocated/unused rather than removed
// so the on-disk layout of every field after it stays put.
char repeat_extra_scopes[24];
// On-disk position is at the struct's append-only tail (0xC0DE002B), even
// though grouped here with the rest of repeat_*. Bit i = scope-list index
// (i+1) is in this repeater's accept set (index 0, "*", isn't a real scope
// so isn't toggleable here) — see ScopeList/MyMesh::rebuildRepeatScopes().
// Same MAX_REPEAT_SCOPES=4 runtime cap as before, just resolved from the
// shared named-scope list instead of comma-tokenizing repeat_extra_scopes.
uint16_t repeat_extra_scope_mask;
// Optional dedicated radio profile for repeater mode. When repeater_use_profile
// is 1, enabling the repeater switches the radio to repeater_freq/bw/sf/cr and
// disabling restores the companion's freq/bw/sf/cr (the fields above). 0 = the
@@ -329,6 +339,16 @@ struct NodePrefs { // persisted to file
// Per-channel melody override (2 bitmasks, 1 bit per channel)
uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody [del→onChannelRemoved]
uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set)
// On-disk position is at the struct's append-only tail (0xC0DE002B), even
// though grouped here with the other per-channel overrides. Scope-list
// index per channel (see ScopeList) -- 0 ("*"/unscoped) is the correct
// zero-init default, matching today's unconfigured behaviour exactly, so
// no migration is needed for this field itself. Fixed at 64 slots (not
// MAX_GROUP_CHANNELS, which varies by board/variant and would make
// sizeof(NodePrefs) variant-dependent) -- same implicit channel-count cap
// every ch_notif_*/ch_fav_bitmask uint64_t bitmask above already has.
static const uint8_t MAX_SCOPED_CHANNELS = 64;
uint8_t ch_scope_idx[MAX_SCOPED_CHANNELS]; // [del→onChannelRemoved]
struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on
static const int DM_NOTIF_TABLE_MAX = 16;
DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes [del→onContactRemoved]
@@ -568,7 +588,7 @@ struct NodePrefs { // persisted to file
// repeat_* fields) instead of at the tail, which shifted every field after
// them by 25 bytes when loading an older file. Never released, but a dev
// build wrote it, so the number must not be reused for anything else.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE002A;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE002B;
// Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
@@ -708,7 +728,12 @@ struct NodePrefs { // persisted to file
// msg_wake_screen_off (0xC0DE002A) landed in the 1 byte of padding the
// 0xC0DE0029 bump left over -- confirmed via a real sim_companion_radio
// (native) build, sizeof unchanged at 2760.
static_assert(sizeof(NodePrefs) == 2760,
// repeat_extra_scope_mask + ch_scope_idx[64] (0xC0DE002B) added 64 bytes,
// not 66 -- the struct had 2 bytes of spare tail padding left over from an
// earlier bump -- confirmed via a real sim_companion_radio (native) build
// and a real WioTrackerL1_companion_solo_dual (nRF52/ARM) build, sizeof
// 2824 on both.
static_assert(sizeof(NodePrefs) == 2824,
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
"SCHEMA_SENTINEL, then update this size (see steps above).");
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#include <Arduino.h>
#include <helpers/TransportKeyStore.h>
#include <helpers/TxtDataHelpers.h>
// Shared named-scope list: Settings > Radio > Scope used to be a single
// free-typed region name/key pair (NodePrefs.default_scope_name/
// default_scope_key). This replaces it with a small definable list, single-
// selected per channel (MessagesScreen's channel context menu) and multi-
// selected by the repeater's accept-filter (RepeaterScreen's Extra scopes,
// NodePrefs.repeat_extra_scope_mask) -- see MyMesh::rebuildRepeatScopes()
// and sendFloodScoped(const mesh::GroupChannel&, ...).
//
// List index 0 is the permanent, non-deletable, non-renamable "*" (wildcard/
// unscoped) -- it is never stored in entries[]/count below, only synthesised
// on read, so it can't be corrupted or migrated away. Real entries live at
// list index 1..count, i.e. entries[idx-1].
struct ScopeEntry {
char name[24];
uint8_t key[16];
};
class ScopeList {
public:
static const uint8_t MAX_SCOPE_ENTRIES = 8; // named entries; list indices 1..MAX_SCOPE_ENTRIES
uint8_t count = 0; // how many of entries[] are in use
uint8_t default_idx = 0; // list index (0 = "*") used for DMs and any
// channel/repeater slot without its own pick
ScopeEntry entries[MAX_SCOPE_ENTRIES];
// Number of selectable list entries, "*" included.
int totalCount() const { return count + 1; }
bool isWildcard(uint8_t idx) const { return idx == 0; }
// Clamp a possibly-stale index (e.g. read from an older/shorter list, or a
// channel's saved pick after entries were deleted) to a valid one.
uint8_t clamp(uint8_t idx) const { return (idx <= count) ? idx : 0; }
const char* name(uint8_t idx) const {
idx = clamp(idx);
return (idx == 0) ? "*" : entries[idx - 1].name;
}
// "*" (and any out-of-range index) resolves to a null TransportKey, which
// sendFloodScoped() already treats as "send unscoped" -- same behaviour a
// never-configured device has today.
TransportKey key(uint8_t idx) const {
TransportKey k;
idx = clamp(idx);
if (idx == 0) memset(k.key, 0, sizeof(k.key));
else memcpy(k.key, entries[idx - 1].key, sizeof(k.key));
return k;
}
// Derives a scope's key the same "#name" -> SHA256 way
// MyMesh::setPrimaryScope() already does -- reused via TransportKeyStore
// rather than re-implemented here.
static void deriveKey(const char* name, uint8_t key[16]) {
char hashtag[1 + 24];
snprintf(hashtag, sizeof(hashtag), "#%s", name);
TransportKeyStore temp;
TransportKey tk;
temp.getAutoKeyFor(0, hashtag, tk);
memcpy(key, tk.key, 16);
}
// Adds a new named entry (deriving its key), returns its list index, or 0
// if the list is full or the name is empty.
uint8_t add(const char* name) {
if (!name || !name[0] || count >= MAX_SCOPE_ENTRIES) return 0;
ScopeEntry& e = entries[count];
StrHelper::strncpy(e.name, name, sizeof(e.name));
deriveKey(e.name, e.key);
count++;
return count; // list index of the new entry
}
// Removes list index idx (1..count) -- index 0 ("*") can't be removed by
// callers since it's never a valid argument here. Shifts later entries
// down to close the gap and fixes up default_idx if it pointed at the
// removed entry or anything after it.
void remove(uint8_t idx) {
if (idx < 1 || idx > count) return;
for (uint8_t i = idx; i < count; i++) entries[i - 1] = entries[i];
count--;
if (default_idx == idx) default_idx = 0;
else if (default_idx > idx) default_idx--;
}
};
@@ -68,6 +68,12 @@ class MessagesScreen : public UIScreen {
uint8_t _ctx_ch_idx = 0;
char _ctx_notif_item[22];
char _ctx_melody_item[20];
char _ctx_scope_item[30]; // "Scope: <name>"
// Scope sub-picker: single-select from the shared named-scope list,
// reusing _ctx_menu itself as a popup -- same idiom as _pin_picker_active's
// "Pick slot" submenu below (row index == list index directly, no
// per-row value cycling needed since Enter just picks and closes).
bool _scope_pick_active = false;
char _ctx_pin_item[28]; // "Pin to dial" or "Unpin (slot N)"
char _ctx_fav_item[12]; // "Fav: ON" / "Fav: OFF" — shared by the channel,
// contact and room menus (never open at once)
@@ -1145,6 +1151,7 @@ public:
_pick_bot_room = false;
_pin_picker_active = false;
_pin_slot_ch_idx = -1;
_scope_pick_active = false;
_ch_delete_confirm_active = false;
_pick_fav_slot = -1;
_direct_entry = false;
@@ -1694,8 +1701,17 @@ public:
ChannelDetails ch;
the_mesh.getChannel(_sel_channel_idx, ch);
char title[24];
snprintf(title, sizeof(title), "%.23s", ch.name);
char title[32];
NodePrefs* p_hdr = _task->getNodePrefs();
uint8_t hdr_sc_idx = (p_hdr && _sel_channel_idx < NodePrefs::MAX_SCOPED_CHANNELS) ? p_hdr->ch_scope_idx[_sel_channel_idx] : 0;
if (hdr_sc_idx != 0) {
// Non-wildcard scope set on this channel -- surface it in the title,
// same as the app's own per-channel scope tag, so it's obvious at a
// glance the channel isn't sending unscoped.
snprintf(title, sizeof(title), "%.16s [%.8s]", ch.name, the_mesh.scopeList().name(hdr_sc_idx));
} else {
snprintf(title, sizeof(title), "%.23s", ch.name);
}
display.drawCenteredHeader(title, true, _ctx_menu.active);
int ch_hist_count = _history.histCountForChannel(_sel_channel_idx);
@@ -2133,11 +2149,20 @@ public:
if (_ctx_menu.active) {
// LEFT/RIGHT -- and Enter, via VALUE_NEXT below -- cycle Notif/Melody/Fav
// in place; the menu stays open and only Back closes it.
if (!_pin_picker_active && !_ch_delete_confirm_active && (keyIsPrev(c) || keyIsNext(c))) {
if (!_pin_picker_active && !_ch_delete_confirm_active && !_scope_pick_active && (keyIsPrev(c) || keyIsNext(c))) {
cycleChannelCtxValue(_ctx_menu.selectedIndex(), keyIsNext(c) ? 1 : -1);
return true;
}
auto res = _ctx_menu.handleInput(c);
if (_scope_pick_active) {
// Scope sub-menu: row index == list index directly ("*" first).
if (res == PopupMenu::SELECTED) {
the_mesh.setChannelScope(_ctx_ch_idx, (uint8_t)_ctx_menu.selectedIndex());
the_mesh.savePrefs();
}
if (res != PopupMenu::NONE) _scope_pick_active = false;
return true;
}
if (_pin_picker_active) {
// Slot picker sub-menu: index 0..FAVOURITES_COUNT-1 maps directly to slot.
if (res == PopupMenu::SELECTED && _pin_slot_ch_idx >= 0) {
@@ -2177,7 +2202,16 @@ public:
int cleared = (int)_history.chUnread(ch_idx);
_history.setChUnread(ch_idx, 0);
markReadAlert(cleared);
} else if (sel == 4) { // Pin / Unpin
} else if (sel == 4) { // Scope
NodePrefs* p2 = _task->getNodePrefs();
uint8_t cur = (p2 && ch_idx < NodePrefs::MAX_SCOPED_CHANNELS) ? p2->ch_scope_idx[ch_idx] : 0;
const ScopeList& sl = the_mesh.scopeList();
_ctx_menu.begin("Scope", 4);
for (uint8_t i = 0; i <= sl.count; i++) _ctx_menu.addItem(sl.name(i));
_ctx_menu.setSelected(cur);
_scope_pick_active = true;
return true; // list rebuild below would close the submenu
} else if (sel == 5) { // Pin / Unpin
int pinned_slot = _task->findFavouriteChannelSlot(ch_idx);
if (pinned_slot >= 0) {
_task->clearFavouriteSlot(pinned_slot);
@@ -2193,10 +2227,10 @@ public:
_pin_picker_active = true;
return true; // list rebuild below would close the submenu
}
} else if (sel == 5) { // Edit
} else if (sel == 6) { // Edit
ChannelDetails ch;
if (the_mesh.getChannel(ch_idx, ch)) _ch_view.openEdit(ch_idx, ch.name);
} else if (sel == 6) { // Delete -- confirm first (destructive)
} else if (sel == 7) { // Delete -- confirm first (destructive)
_ctx_menu.beginConfirm("Delete channel?", "Delete");
_ch_delete_confirm_active = true;
return true; // list rebuild below would close the submenu
@@ -2259,12 +2293,16 @@ public:
{ int pinned_slot = _task->findFavouriteChannelSlot(ch_idx);
if (pinned_slot >= 0) snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Unpin (slot %d)", pinned_slot + 1);
else snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Pin to dial"); }
{ NodePrefs* p2 = _task->getNodePrefs();
uint8_t sc_idx = (p2 && ch_idx < NodePrefs::MAX_SCOPED_CHANNELS) ? p2->ch_scope_idx[ch_idx] : 0;
snprintf(_ctx_scope_item, sizeof(_ctx_scope_item), "Scope: %s", the_mesh.scopeList().name(sc_idx)); }
_ctx_menu.begin("Channel options", 6);
_ctx_menu.addItem("Mark all read");
_ctx_menu.addValueItem(_ctx_notif_item);
_ctx_menu.addValueItem(_ctx_melody_item);
_ctx_fav_idx = 3;
_ctx_menu.addValueItem(_ctx_fav_item);
_ctx_menu.addItem(_ctx_scope_item);
_ctx_menu.addItem(_ctx_pin_item);
_ctx_menu.addItem("Edit");
_ctx_menu.addItem("Delete");
+31 -3
View File
@@ -20,13 +20,15 @@ struct PopupMenu {
// than an action to run, so Enter advances the value and leaves the menu open
// (see handleInput). One bit per row; PM_MAX_ITEMS fits in a uint32_t.
uint32_t _value_mask;
bool _has_checkboxes; // true once addCheckItem() has been used this begin()
uint32_t _checked_mask; // per-row checkbox state (addCheckItem/setChecked)
// VALUE_NEXT: Enter landed on a value row -- caller advances that row's value
// (same as its RIGHT step) and the menu stays open.
enum Result { NONE, SELECTED, CANCELLED, VALUE_NEXT };
PopupMenu() : _count(0), _sel(0), _scroll(0), _cap(3), active(false), _title(nullptr),
_value_mask(0) {}
_value_mask(0), _has_checkboxes(false), _checked_mask(0) {}
// `visible` is only a seed for the first frame: render() recomputes _cap from
// the live display height, so it does not cap or pad the item list.
@@ -34,6 +36,7 @@ struct PopupMenu {
_count = 0; _sel = 0; _scroll = 0;
_cap = visible; active = true; _title = title;
_value_mask = 0;
_has_checkboxes = false; _checked_mask = 0;
}
void addItem(const char* item) {
@@ -49,6 +52,29 @@ struct PopupMenu {
if (_count > i) _value_mask |= (1u << i);
}
// A checklist row: a value row (Enter toggles it and stays open, same
// VALUE_NEXT contract as addValueItem()) that also draws a fillable-square
// checkbox to the right of its label instead of the caller baking "[x]"/
// "[ ]" into the text itself -- see icons.h's drawCheckbox(), the same
// glyph SettingsScreen's volume/brightness bars use. Once any row uses
// this, every row in the menu reserves the checkbox gutter (plain/
// addValueItem rows just render without a box in it) -- mixing styles
// isn't a use case this menu has today.
void addCheckItem(const char* item, bool checked) {
int i = _count;
addValueItem(item);
if (_count > i) {
_has_checkboxes = true;
setChecked(i, checked);
}
}
void setChecked(int i, bool on) {
if (i < 0 || i >= _count) return;
if (on) _checked_mask |= (1u << i);
else _checked_mask &= ~(1u << i);
}
bool isChecked(int i) const { return (_checked_mask >> i) & 1; }
// A two-row Action/Cancel confirm for a destructive or hard-to-reverse
// action, defaulting the highlight to Cancel (row 1) so accepting it takes
// a deliberate move up. Same shape every such confirm in the UI uses --
@@ -107,7 +133,8 @@ struct PopupMenu {
int w = display.getTextWidth(_items[i]);
if (w > content_w) content_w = w;
}
int bw = content_w + pad * 2 + arrow_w;
int box_w = _has_checkboxes ? (checkboxWidth(display) + pad) : 0;
int bw = content_w + pad * 2 + arrow_w + box_w;
int max_bw = display.width() - margin * 2;
if (bw > max_bw) bw = max_bw;
int min_bw = cw * 6 + pad * 2;
@@ -130,7 +157,7 @@ struct PopupMenu {
display.fillRect(bx, by + lh + 2, bw, sh); // separator just under the title; gap follows
int list_y = by + title_h;
int text_w = bw - pad * 2 - arrow_w;
int text_w = bw - pad * 2 - arrow_w - box_w;
if (text_w < cw) text_w = cw;
for (int i = 0; i < vis && (_scroll + i) < _count; i++) {
int idx = _scroll + i;
@@ -148,6 +175,7 @@ struct PopupMenu {
// Return value not needed here: this popup already redraws every 50ms
// (below), faster than any marquee step, so the animation is already smooth.
display.drawTextEllipsized(bx + pad, py, text_w, _items[idx], idx == _sel);
if (_has_checkboxes) drawCheckbox(display, bx + bw - arrow_w - pad - checkboxWidth(display), py, isChecked(idx));
display.setColor(DisplayDriver::LIGHT);
}
@@ -23,6 +23,7 @@
#include "RadioPresetPicker.h"
#include "../RadioPresets.h"
#include "../MyMesh.h"
#include "PopupMenu.h"
extern MyMesh the_mesh;
@@ -38,7 +39,8 @@ class RepeaterScreen : public UIScreen {
IT_SKIP, IT_HOPS, IT_YIELD, IT_SNR, IT_SUPPRESS, IT_SCOPE, IT_SCOPE_EXTRA
};
uint8_t _items[14];
bool _editing_scope; // keyboard is entering/editing the extra scopes
bool _scope_picker_active = false; // multi-select popup over the extra scopes
PopupMenu _scope_menu;
int _item_count;
RadioPresetPicker _picker;
@@ -127,9 +129,20 @@ class RepeaterScreen : public UIScreen {
break;
case IT_SUPPRESS: strncpy(buf, p->repeat_suppress_dup ? "ON" : "OFF", n); break;
case IT_SCOPE: strncpy(buf, p->repeat_scope_only ? "ON" : "OFF", n); break;
case IT_SCOPE_EXTRA:
strncpy(buf, p->repeat_extra_scopes[0] ? p->repeat_extra_scopes : "(none)", n);
case IT_SCOPE_EXTRA: {
// Bound the scan to the list's real length, not the mask's full bit
// width -- a stray high bit (e.g. leftover sentinel bytes from a
// pre-scope-list save file) must never be counted as "picked", or a
// device that's never touched this picker can show a bogus non-zero
// count. rebuildRepeatScopes() already ignores such bits the same way.
uint8_t total = the_mesh.scopeList().count;
int picked = 0;
for (uint8_t i = 0; i < total; i++)
if (p->repeat_extra_scope_mask & (1u << i)) picked++;
if (total == 0) strncpy(buf, "(none)", n);
else snprintf(buf, n, "%d/%d", picked, total);
break;
}
default: strncpy(buf, "", n); break;
}
buf[n - 1] = '\0';
@@ -143,17 +156,17 @@ class RepeaterScreen : public UIScreen {
}
public:
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1), _editing_scope(false) {}
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {}
void onShow() override {
_dirty = false; _sel = 0; _scroll = 0;
_picker.menu.active = false; _editor.freq.active = false;
_picker.saving = false; _picker.deleting = false; _picker.confirm_slot = -1;
_editing_scope = false;
_scope_picker_active = false; _scope_menu.active = false;
}
int render(DisplayDriver& display) override {
if (_picker.saving || _editing_scope) return _task->keyboard().render(display);
if (_picker.saving) return _task->keyboard().render(display);
NodePrefs* p = _task->getNodePrefs();
buildItems(p);
@@ -178,7 +191,8 @@ public:
});
display.setColor(DisplayDriver::LIGHT);
if (_picker.menu.active) _picker.menu.render(display);
return (_picker.menu.active || _editor.active()) ? 50 : 500;
if (_scope_picker_active) _scope_menu.render(display);
return (_picker.menu.active || _editor.active() || _scope_picker_active) ? 50 : 500;
}
bool handleInput(char c) override {
@@ -199,19 +213,22 @@ public:
return true;
}
// Keyboard editing mode for the extra (relay-only) scopes
if (_editing_scope) {
auto res = _task->keyboard().handleInput(c);
if (res == KeyboardWidget::DONE) {
if (p) {
strncpy(p->repeat_extra_scopes, _task->keyboard().buf, sizeof(p->repeat_extra_scopes) - 1);
p->repeat_extra_scopes[sizeof(p->repeat_extra_scopes) - 1] = '\0';
the_mesh.rebuildRepeatScopes();
_dirty = true;
}
_editing_scope = false;
} else if (res == KeyboardWidget::CANCELLED) {
_editing_scope = false;
// Multi-select popup for the extra (relay-only) scopes -- each row is a
// checklist item (see PopupMenu::addCheckItem()); Enter toggles it in
// place via VALUE_NEXT and commits straight into prefs, no separate
// "apply on close" step needed.
if (_scope_picker_active) {
auto res = _scope_menu.handleInput(c);
if (res == PopupMenu::VALUE_NEXT && p) {
int i = _scope_menu.selectedIndex();
bool now_on = !_scope_menu.isChecked(i);
_scope_menu.setChecked(i, now_on);
if (now_on) p->repeat_extra_scope_mask |= (1u << i);
else p->repeat_extra_scope_mask &= ~(uint16_t)(1u << i);
the_mesh.rebuildRepeatScopes();
_dirty = true;
} else if (res != PopupMenu::NONE && res != PopupMenu::VALUE_NEXT) {
_scope_picker_active = false; // Back closes it -- checklist has no plain-select row
}
return true;
}
@@ -319,8 +336,15 @@ public:
p->repeat_scope_only ^= 1; _dirty = true; return true;
}
if (item == IT_SCOPE_EXTRA && enter) {
_task->keyboard().begin(p->repeat_extra_scopes, (int)sizeof(p->repeat_extra_scopes) - 1);
_editing_scope = true;
const ScopeList& sl = the_mesh.scopeList();
if (sl.count == 0) {
_task->showAlert("No scopes defined", 1200);
return true;
}
_scope_menu.begin("Extra scopes", sl.count < 4 ? sl.count : 4);
for (uint8_t i = 0; i < sl.count; i++)
_scope_menu.addCheckItem(sl.name((uint8_t)(i + 1)), (p->repeat_extra_scope_mask & (1u << i)) != 0);
_scope_picker_active = true;
return true;
}
return false;
+105 -13
View File
@@ -7,6 +7,7 @@
#include "RadioParamsEditor.h"
#include "RadioPresetPicker.h"
#include "AccordionList.h"
#include "PopupMenu.h" // scope list management's per-row action menu
class SettingsScreen : public UIScreen {
UITask* _task;
@@ -568,8 +569,9 @@ class SettingsScreen : public UIScreen {
} else if (item == SCOPE_NAME) {
display.print("Scope");
int vx = valCol(display);
const ScopeList& sl = the_mesh.scopeList();
int r = display.drawTextEllipsized(vx, y, display.width() - vx - _reserve,
(p && p->default_scope_name[0]) ? p->default_scope_name : "(none)", sel);
sl.name(sl.default_idx), sel);
if (sel && r > 0) mq_delay = r;
#if AUTO_OFF_MILLIS > 0
} else if (item == AUTO_OFF) {
@@ -701,9 +703,45 @@ class SettingsScreen : public UIScreen {
// Keyboard state for editing message slots
int _edit_slot = -1; // -1 = not editing, 0..9 = slot being edited
bool _edit_name = false; // editing DEVICE_NAME via the keyboard
bool _edit_scope = false; // editing SCOPE_NAME via the keyboard
KeyboardWidget* _kb;
// Scope list management (SCOPE_NAME row -> a full-screen add/rename/
// delete/set-default list over the shared named-scope list, ScopeList.h).
// Row 0 is always "*", rows 1..count are named entries, the trailing row
// is a synthetic "+ Add scope" -- same shape as MessagesScreen's channel
// picker. _scope_action_menu is the per-row Set default/Rename/Delete
// popup (Enter on a row); the keyboard (_kb) is reused for both Add and
// Rename's name entry, told apart by _scope_rename_idx (-1 = adding new).
bool _scope_mgmt_active = false;
int _scope_mgmt_sel = 0, _scope_mgmt_scroll = 0;
PopupMenu _scope_action_menu;
int _scope_action_idx = -1; // list index the open action menu targets
// -2 = _kb not open for a scope name; -1 = _kb is adding a new scope;
// >=0 = _kb is renaming that list index.
int _scope_rename_idx = -2;
bool _scope_delete_confirm_active = false;
int renderScopeMgmt(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("SCOPE", true, _scope_action_menu.active);
const ScopeList& sl = the_mesh.scopeList();
int total = sl.totalCount() + 1; // +1 synthetic "+ Add scope" row
drawList(display, total, _scope_mgmt_sel, _scope_mgmt_scroll, [&](int idx, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
if (idx == sl.totalCount()) {
display.print("+ Add scope");
} else {
display.print(sl.name((uint8_t)idx));
if ((uint8_t)idx == sl.default_idx)
display.drawTextRightAlign(display.width() - reserve - 2, y, "[default]");
}
display.setColor(DisplayDriver::LIGHT);
});
if (_scope_action_menu.active) _scope_action_menu.render(display);
return _scope_action_menu.active ? 50 : 500;
}
// Radio preset picker — names are too long for the value column, so Enter on
// RADIO_PRESET opens it as a full-width scrollable list instead of cycling.
// Shared with Tools Repeater (see RadioPresetPicker.h). _picker.saving means
@@ -725,7 +763,10 @@ public:
void onShow() override {
_dirty = false;
_edit_name = false;
_edit_scope = false;
_scope_mgmt_active = false;
_scope_rename_idx = -2;
_scope_action_menu.active = false;
_scope_delete_confirm_active = false;
resetList();
_editor.freq.active = false;
}
@@ -733,10 +774,12 @@ public:
int render(DisplayDriver& display) override {
display.setTextSize(1);
if (_edit_slot >= 0 || _edit_name || _edit_scope || _picker.saving) {
if (_edit_slot >= 0 || _edit_name || _scope_rename_idx != -2 || _picker.saving) {
return _kb->render(display);
}
if (_scope_mgmt_active) return renderScopeMgmt(display);
display.drawCenteredHeader("SETTINGS");
int mq_delay = 0;
@@ -798,19 +841,68 @@ public:
return true;
}
// Keyboard editing mode for the scope name
if (_edit_scope) {
// Keyboard editing mode for adding/renaming a scope-list entry
if (_scope_rename_idx != -2) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
the_mesh.setPrimaryScope(_kb->buf);
_dirty = true;
_edit_scope = false;
if (_scope_rename_idx == -1) the_mesh.addScope(_kb->buf);
else the_mesh.renameScope((uint8_t)_scope_rename_idx, _kb->buf);
_scope_rename_idx = -2;
} else if (res == KeyboardWidget::CANCELLED) {
_edit_scope = false;
_scope_rename_idx = -2;
}
return true;
}
if (_scope_mgmt_active) {
const ScopeList& sl = the_mesh.scopeList();
if (_scope_delete_confirm_active) {
auto res = _scope_action_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _scope_action_menu.selectedIndex() == 0) { // "Delete"
the_mesh.removeScope((uint8_t)_scope_action_idx);
if (_scope_mgmt_sel > sl.totalCount()) _scope_mgmt_sel = sl.totalCount();
}
if (res != PopupMenu::NONE) _scope_delete_confirm_active = false;
return true;
}
if (_scope_action_menu.active) {
auto res = _scope_action_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
int sel = _scope_action_menu.selectedIndex();
if (sel == 0) { // Set default
the_mesh.setDefaultScope((uint8_t)_scope_action_idx);
} else if (sel == 1 && _scope_action_idx >= 1) { // Rename
_scope_rename_idx = _scope_action_idx;
_kb->begin(sl.name((uint8_t)_scope_action_idx), 23);
_kb->clearPlaceholders();
} else if (sel == 2 && _scope_action_idx >= 1) { // Delete -- confirm first
_scope_action_menu.beginConfirm("Delete scope?", "Delete");
_scope_delete_confirm_active = true;
}
}
return true;
}
if (c == KEY_CANCEL) { _scope_mgmt_active = false; return true; }
int total = sl.totalCount() + 1;
if (c == KEY_UP) { _scope_mgmt_sel = (_scope_mgmt_sel > 0) ? _scope_mgmt_sel - 1 : total - 1; return true; }
if (c == KEY_DOWN) { _scope_mgmt_sel = (_scope_mgmt_sel + 1 < total) ? _scope_mgmt_sel + 1 : 0; return true; }
if (c == KEY_ENTER) {
if (_scope_mgmt_sel == sl.totalCount()) { // "+ Add scope"
_scope_rename_idx = -1;
_kb->begin("", 23);
_kb->clearPlaceholders();
} else {
_scope_action_idx = _scope_mgmt_sel;
bool is_named = _scope_action_idx >= 1;
_scope_action_menu.begin("Scope", is_named ? 3 : 1);
_scope_action_menu.addItem("Set default");
if (is_named) { _scope_action_menu.addItem("Rename"); _scope_action_menu.addItem("Delete"); }
}
return true;
}
return true; // list has focus -- swallow anything else rather than falling through
}
// Digit-by-digit Freq editor
if (_editor.active()) {
if (_editor.handleFreqInput(c) && p) { _task->applyRadioParams(); _dirty = true; }
@@ -1012,9 +1104,9 @@ public:
return true;
}
if (_selected == SCOPE_NAME && p && enter) {
_edit_scope = true;
_kb->begin(p->default_scope_name, (int)sizeof(p->default_scope_name) - 1);
_kb->clearPlaceholders(); // a scope name is literal, not a message
_scope_mgmt_active = true;
_scope_mgmt_sel = 0;
_scope_mgmt_scroll = 0;
return true;
}
if (_selected == REBOOT && enter) {
@@ -3306,6 +3306,10 @@ void UITask::onChannelRemoved(uint8_t channel_idx) {
_node_prefs->ch_fav_bitmask &= ~mask;
changed = true;
}
if (channel_idx < NodePrefs::MAX_SCOPED_CHANNELS && _node_prefs->ch_scope_idx[channel_idx]) {
_node_prefs->ch_scope_idx[channel_idx] = 0; // back to "*", same as a never-configured channel
changed = true;
}
int fav_slot = findFavouriteChannelSlot(channel_idx);
if (fav_slot >= 0) { clearFavouriteSlot(fav_slot); changed = true; }
+14
View File
@@ -776,3 +776,17 @@ inline int favStarWidth(DisplayDriver& d) {
inline void drawFavStar(DisplayDriver& d, int x, int top_y) {
miniIconDraw(d, x, top_y, ICON_PG_STAR);
}
// Multi-select toggle glyph -- an outlined box, filled solid when `on`, same
// visual language as SettingsScreen's volume/brightness renderBar() (a
// fillable square) rather than a "[x]"/"[ ]" text glyph that competes with
// the row's own label for width. Used by PopupMenu's checklist rows and any
// other multi-select list.
inline int checkboxWidth(DisplayDriver& d) {
return d.getLineHeight() - 2;
}
inline void drawCheckbox(DisplayDriver& d, int x, int y, bool on) {
int box = checkboxWidth(d);
d.drawRect(x, y, box, box);
if (on) d.fillRect(x + 2, y + 2, box - 4, box - 4);
}