Files
MeshCore-Solo/examples/companion_radio/ui-new/SettingsScreen.h
T
JakubandClaude Opus 5.5 c5bb4752d0 feat: per-contact actions, device settings, a companion CLI and your contact's QR code
- Nodes: telemetry permissions per contact, reset path, share nearby
  (L1 options menu; L2 node card with "Can ask for" switches)
- Settings > Privacy: fixed position (typed in, from a waypoint, cleared)
  and the Bluetooth PIN; Settings > System: factory reset, asked twice
- Companion CLI (CMD_RUN_CLI_COMMAND 66 / RESP_CODE_CLI_REPLY 29, as
  upstream; FIRMWARE_VER_CODE 14): upstream's command names plus a key for
  every schema setting (get / set / keys)
- Screenshots move to 0x7E; tools/screenshot.py falls back to 66 / 29
- Your contact as a QR code (meshcore://contact/add): Status > System on
  ui-new, Settings > About on the L2; Nayuki's qrcodegen made header-only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:59:35 +02:00

1235 lines
48 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
// Custom screen — not part of upstream UITask.cpp
// Included by UITask.cpp after SensorPlaceholders.h is defined.
#include "../Features.h"
#include "../RadioPresets.h"
#include "RadioParamsEditor.h"
#include "RadioPresetPicker.h"
#include "AccordionList.h"
#include "PopupMenu.h" // scope list management's per-row action menu
#include "InfoKit.h" // value / switch rows
#include "../ui-core/Battery.h" // the Battery curve row
#include "../GeoUtils.h" // Position typed in
class SettingsScreen : public UIScreen {
UITask* _task;
enum SettingItem {
// Display section
SECTION_DISPLAY,
SCHEMA_DISPLAY, // the schema's display and time settings (ui-core/SettingsSchema.h)
#if FEAT_DISPLAY_ROTATION_SETTING
ROTATION,
#endif
#if FEAT_JOYSTICK_ROTATION_SETTING
JOY_ROTATION,
#endif
#if FEAT_FULL_REFRESH_SETTING
EINK_FULL_REFRESH,
#endif
// Sound section
SECTION_SOUND,
BUZZER,
SCHEMA_SOUND, // volume, quiet hours, what plays for what
// Home pages section
SECTION_HOME_PAGES,
HOME_CLOCK, HOME_FAVOURITES, HOME_STATUS, HOME_BT, HOME_ADVERT,
HOME_SETTINGS, HOME_QUICK_MSG,
HOME_TOOLS, HOME_SHUTDOWN, HOME_MAP,
// Radio section
SECTION_RADIO,
TX_POWER,
RADIO_PRESET,
CUSTOM_FREQ, CUSTOM_SF, CUSTOM_BW, CUSTOM_CR,
#if FEAT_RX_POWERSAVE
POWER_SAVE,
#endif
TX_APC,
SCOPE_NAME,
SCHEMA_RADIO, // the advanced radio options
// System section
SECTION_SYSTEM,
DEVICE_NAME,
SCHEMA_SYSTEM, // power, units
BATT_CURVE,
REBOOT,
FACTORY_RESET,
// Keyboard section
SECTION_KEYBOARD,
KEYBOARD_TYPE,
KEYBOARD_MAIN_ALPHABET,
KEYBOARD_ALPHABET,
#if defined(CARDKB_I2C)
KEYBOARD_CARDKB_COMPACT,
#endif
// Contacts section
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, SCHEMA_CONTACTS, PRUNE_NOW, SCHEMA_AUTO_ADD,
// Privacy section: what adverts carry, who may ask for telemetry, the pairing PIN
SECTION_PRIVACY, SCHEMA_PRIVACY, OWN_POSITION, SCHEMA_TELEMETRY,
#ifdef BLE_PIN_CODE
BLE_PIN,
#endif
// Messages section
SECTION_MESSAGES,
SCHEMA_MESSAGES,
MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4,
MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9,
Count,
// Not walked from the enum: placed right after the schema's "Lock screen" row.
LOCK_PIN
};
// A schema setting's row: SCHEMA_ITEM + its index in settings::ALL.
static const int SCHEMA_ITEM = 128;
// Cursor + scroll, fold state and the flattened visible list are owned by the
// shared AccordionList helper. We keep only the section→SettingItem mapping it
// needs (sections are walked once from the enum, honouring the #if guards).
int _selected = 0; // SettingItem under the cursor, resolved per input/render
int _reserve = 0; // right-edge px reserved for the scrollbar (0 when list fits)
bool _dirty = false;
bool _keep_place = false; // set when opening a screen from a row, so Back lands on it
AccordionList _acc;
static const int NUM_SECTIONS = 9;
static const int MAX_PER_SEC = 20;
uint8_t _sec_items[NUM_SECTIONS][MAX_PER_SEC]; // SettingItem per (section, row)
uint8_t _sec_count[NUM_SECTIONS];
uint8_t _sec_header[NUM_SECTIONS]; // the SECTION_* enum for each section
int _num_sections = 0;
#if FEAT_FULL_REFRESH_SETTING
static const char* EINK_FULL_REFRESH_LABELS[5];
static const int EINK_FULL_REFRESH_COUNT = 5;
#endif
// The companion's own radio fields, as the shared preset picker's target
// (Tools › Repeater points the same picker at the dedicated repeater profile).
RadioPresetPicker::Target radioTarget(NodePrefs* p) const {
return { &p->freq, &p->bw, &p->sf, &p->cr };
}
// Value column start, pulled left by the scrollbar gutter so right-side
// values never render under the indicator when the list scrolls.
int valCol(DisplayDriver& display) const { return display.valCol() - _reserve; }
// Shared 0/90/180/270 labels for display + joystick rotation.
static const char* rotLabel(uint8_t r) {
static const char* const L[] = { "0 deg", "90 deg", "180 deg", "270 deg" };
return L[r & 3];
}
// Level boxes ending at the row's right edge, like every other value.
void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) {
const int gap = 2;
const int right = display.width() - _reserve - 2;
const int avail = right - x;
const int raw = (avail - (max_val - 1) * gap) / max_val;
const int cap = display.getLineHeight() - 2;
const int box_h = raw < cap ? (raw < 2 ? 2 : raw) : cap;
const int box_w = box_h;
x = right - (max_val * box_w + (max_val - 1) * gap);
for (int i = 0; i < max_val; i++) {
int bx = x + i * (box_w + gap);
display.drawRect(bx, y, box_w, box_h);
if (i < value)
display.fillRect(bx + 1, y + 1, box_w - 2, box_h - 2);
}
}
bool isSection(int item) const {
return item == SECTION_DISPLAY || item == SECTION_SOUND ||
item == SECTION_HOME_PAGES ||
item == SECTION_RADIO || item == SECTION_SYSTEM ||
item == SECTION_KEYBOARD ||
item == SECTION_CONTACTS || item == SECTION_MESSAGES ||
item == SECTION_PRIVACY;
}
const char* sectionName(int item) const {
if (item == SECTION_DISPLAY) return "Display";
if (item == SECTION_SOUND) return "Sound";
if (item == SECTION_HOME_PAGES) return "Home Pages";
if (item == SECTION_RADIO) return "Radio";
if (item == SECTION_SYSTEM) return "System";
if (item == SECTION_KEYBOARD) return "Keyboard";
if (item == SECTION_CONTACTS) return "Contacts";
if (item == SECTION_MESSAGES) return "Messages";
if (item == SECTION_PRIVACY) return "Privacy";
return "";
}
// Walk the SettingItem enum once, bucketing items under their section header.
// #if-guarded items need no special handling — they simply aren't in the enum.
// A SCHEMA_* placeholder becomes the schema's settings of its sections, in
// the schema's order -- so a setting added there shows here too.
void buildSections() {
int cur = -1;
for (int i = 0; i < (int)Count; i++) {
if (isSection(i)) {
if (++cur >= NUM_SECTIONS) break;
_sec_header[cur] = (uint8_t)i;
_sec_count[cur] = 0;
} else if (cur >= 0 && isSchemaGroup(i)) {
for (int k = 0; k < settings::COUNT && _sec_count[cur] < MAX_PER_SEC; k++)
if (schemaIn(i, settings::ALL[k].section) && schemaShown(settings::ALL[k])) {
_sec_items[cur][_sec_count[cur]++] = (uint8_t)(SCHEMA_ITEM + k);
if (settings::ALL[k].offset == offsetof(NodePrefs, auto_lock) && _sec_count[cur] < MAX_PER_SEC)
_sec_items[cur][_sec_count[cur]++] = (uint8_t)LOCK_PIN;
}
} else if (cur >= 0 && _sec_count[cur] < MAX_PER_SEC) {
_sec_items[cur][_sec_count[cur]++] = (uint8_t)i;
}
}
_num_sections = cur + 1;
}
static bool isSchemaGroup(int item) {
return item == SCHEMA_DISPLAY || item == SCHEMA_SOUND || item == SCHEMA_SYSTEM ||
item == SCHEMA_CONTACTS || item == SCHEMA_MESSAGES || item == SCHEMA_RADIO || item == SCHEMA_AUTO_ADD ||
item == SCHEMA_PRIVACY || item == SCHEMA_TELEMETRY;
}
// Which schema sections a placeholder stands for.
static bool schemaIn(int group, uint8_t sec) {
using namespace settings;
switch (group) {
case SCHEMA_DISPLAY: return sec == SEC_DISPLAY || sec == SEC_TIME;
case SCHEMA_SOUND: return sec == SEC_SOUND || sec == SEC_QUIET || sec == SEC_SOUND_FOR;
case SCHEMA_SYSTEM: return sec == SEC_POWER || sec == SEC_UNITS;
case SCHEMA_CONTACTS: return sec == SEC_CONTACTS;
case SCHEMA_AUTO_ADD: return sec == SEC_AUTO_ADD;
case SCHEMA_RADIO: return sec == SEC_RADIO_ADV;
case SCHEMA_PRIVACY: return sec == SEC_PRIVACY;
case SCHEMA_TELEMETRY: return sec == SEC_TELEMETRY;
case SCHEMA_MESSAGES: return sec == SEC_MESSAGES;
}
return false;
}
// Settings this board has no use for.
static bool schemaShown(const settings::Setting& st) {
uint16_t o = st.offset;
(void)o;
#if !FEAT_BRIGHTNESS_SETTING
if (o == offsetof(NodePrefs, display_brightness)) return false;
#endif
#if AUTO_OFF_MILLIS == 0
if (o == offsetof(NodePrefs, auto_off_secs)) return false;
#endif
#if !FEAT_CLOCK_SECONDS_SETTING
if (o == offsetof(NodePrefs, clock_hide_seconds)) return false;
#endif
#if ENV_INCLUDE_GPS != 1
if (o == offsetof(NodePrefs, gps_interval)) return false;
#endif
#ifndef PIN_BUZZER
if (o == offsetof(NodePrefs, buzzer_volume) || o == offsetof(NodePrefs, notif_melody_dm) ||
o == offsetof(NodePrefs, notif_melody_ch) || o == offsetof(NodePrefs, notif_melody_ad) ||
o == offsetof(NodePrefs, advert_sound_scope)) return false;
#endif
return true;
}
// The number pad for the lock PIN, with what it's asking for.
void beginPinEntry(const char* prompt) {
_kb->beginPin("", KeyboardWidget::PIN_MAX_LEN, false, prompt);
_kb->clearPlaceholders(); // a PIN is literal, not a template message
}
// Brightness and volume: a row of level boxes, stopping at both ends.
static bool isBar(const settings::Setting& st) {
return st.offset == offsetof(NodePrefs, display_brightness) || st.offset == offsetof(NodePrefs, buzzer_volume);
}
// A schema setting: its short label, the value flush right (a switch for
// on / off, brightness and volume as bars); a value too long for the room
// beside the label is cut, and scrolls while selected.
int renderSchema(DisplayDriver& display, const settings::Setting& st, NodePrefs* p, int y, bool sel) {
const char* label = settings::shortLabel(st);
if (!p) { display.setCursor(2, y); display.print(label); return 0; }
if (isBar(st)) {
display.setCursor(2, y);
display.print(label);
renderBar(display, valCol(display), y, settings::get(*p, st) + 1, st.count);
return 0;
}
if (!st.option) { // a switch
info::switchRow(display, y, label, settings::get(*p, st) != 0, sel, _reserve);
return 0;
}
char v[24];
settings::text(*p, st, v, sizeof(v));
return info::valueRow(display, y, label, v, sel, _reserve);
}
// (Re)load the section sizes into the accordion (folds all, resets the cursor).
void resetList() {
uint8_t sizes[NUM_SECTIONS];
for (int i = 0; i < _num_sections; i++) sizes[i] = _sec_count[i];
_acc.begin(sizes, _num_sections);
}
// Resolve the accordion's selected row to a SettingItem (header → SECTION_*).
int currentItem() const {
const AccordionList::Row& r = _acc.selected();
return (r.item < 0) ? _sec_header[r.sec] : _sec_items[r.sec][r.item];
}
bool isHomePage(int item) const {
return item == HOME_CLOCK || item == HOME_STATUS || item == HOME_BT ||
item == HOME_ADVERT || item == HOME_TOOLS ||
item == HOME_SHUTDOWN || item == HOME_SETTINGS || item == HOME_QUICK_MSG ||
item == HOME_FAVOURITES || item == HOME_MAP;
}
uint16_t homePageBit(int item) const {
int bit = homePageBitIndex(item);
// SETTINGS and QUICK_MSG are always visible (no mask bit). All other pages
// — including FAVOURITES — toggle via home_pages_mask.
if (bit < 0 || bit == NodePrefs::HPB_SETTINGS || bit == NodePrefs::HPB_QUICK_MSG) return 0;
// Status took over the Radio, GPS and Sensors pages: it owns all three bits.
if (item == HOME_STATUS) return NodePrefs::HP_RADIO | NodePrefs::HP_GPS | NodePrefs::HP_SENSORS;
return (uint16_t)(1 << bit);
}
const char* homePageLabel(int item) const {
int bit = homePageBitIndex(item);
return NodePrefs::homePageLabel((uint8_t)(bit >= 0 ? bit : NodePrefs::HPB_COUNT));
}
bool homePageVisible(int item, const NodePrefs* p) const {
if (item == HOME_SETTINGS || item == HOME_QUICK_MSG) return true;
uint16_t bit = homePageBit(item);
if (!bit) return false;
uint16_t mask = (p && p->home_pages_mask) ? p->home_pages_mask : NodePrefs::HP_ALL;
return (mask & bit) != 0;
}
bool homePageToggleable(int item) const {
return item != HOME_SETTINGS && item != HOME_QUICK_MSG;
}
// Returns the bit-index used in page_order for this SettingItem, or -1.
// Bit-index values are defined once in NodePrefs::HomePageBit.
int homePageBitIndex(int item) const {
if (item == HOME_CLOCK) return NodePrefs::HPB_CLOCK;
if (item == HOME_FAVOURITES) return NodePrefs::HPB_FAVOURITES;
if (item == HOME_STATUS) return NodePrefs::HPB_RADIO; // the old Radio slot
if (item == HOME_BT) return NodePrefs::HPB_BLUETOOTH;
if (item == HOME_ADVERT) return NodePrefs::HPB_ADVERT;
if (item == HOME_TOOLS) return NodePrefs::HPB_TOOLS;
if (item == HOME_SHUTDOWN) return NodePrefs::HPB_SHUTDOWN;
if (item == HOME_MAP) return NodePrefs::HPB_MAP;
if (item == HOME_SETTINGS) return NodePrefs::HPB_SETTINGS;
if (item == HOME_QUICK_MSG) return NodePrefs::HPB_QUICK_MSG;
return -1;
}
// Returns 1-based position of item in page_order, or 0 if no custom order / not found.
int homePagePosition(int item, const NodePrefs* p) const {
if (!p || p->page_order_set != NodePrefs::PAGE_ORDER_MAGIC) return 0;
int bit = homePageBitIndex(item);
if (bit < 0) return 0;
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = p->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
if ((int)(v - 1) == bit) return i + 1;
}
return 0;
}
// Initialises page_order to the default display sequence if not already set.
// Also repairs a partially-initialised order where CLOCK is absent, and
// appends any pages absent from a saved order (pages a later firmware added).
void ensurePageOrderInit(NodePrefs* p) const {
if (!p) return;
if (p->page_order_set == NodePrefs::PAGE_ORDER_MAGIC) {
bool has_clock = false;
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = p->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
if ((int)(v - 1) == NodePrefs::HPB_CLOCK) has_clock = true;
}
if (!has_clock) {
// Corrupted/partial — full re-init below.
memset(p->page_order, 0, sizeof(p->page_order));
} else {
// Append any pages that are absent from the saved order (e.g. added by a
// later firmware version).
{
uint16_t present = 0;
int cur_len = 0;
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = p->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
present |= (uint16_t)(1u << (v - 1));
cur_len++;
}
// Every page has a slot now (PAGE_ORDER_LEN == HPB_COUNT), so all pages
// are required — any missing from a saved order is appended into the
// free tail slots below.
static const uint8_t REQUIRED[] = {
NodePrefs::HPB_CLOCK, NodePrefs::HPB_FAVOURITES,
NodePrefs::HPB_RECENT, NodePrefs::HPB_RADIO,
NodePrefs::HPB_BLUETOOTH, NodePrefs::HPB_ADVERT,
#if ENV_INCLUDE_GPS == 1
NodePrefs::HPB_GPS,
#endif
#if UI_SENSORS_PAGE == 1
NodePrefs::HPB_SENSORS,
#endif
NodePrefs::HPB_SETTINGS, NodePrefs::HPB_MAP, NodePrefs::HPB_TOOLS,
NodePrefs::HPB_QUICK_MSG, NodePrefs::HPB_SHUTDOWN,
};
for (int ri = 0; ri < (int)(sizeof(REQUIRED)/sizeof(REQUIRED[0])); ri++) {
uint8_t bit = REQUIRED[ri];
if (!(present & (uint16_t)(1u << bit)) && cur_len < NodePrefs::PAGE_ORDER_LEN)
p->page_order[cur_len++] = bit + 1;
}
}
return;
}
}
// Default: CLOCK FAVOURITES RECENT RADIO BT ADVERT [GPS] [SENSORS] SETTINGS
// MAP TOOLS MESSAGES SHUTDOWN. Every bit keeps a slot (PAGE_ORDER_LEN ==
// HPB_COUNT), retired ones too: RECENT shows nowhere, and Status stands in
// the RADIO slot for the old Radio, GPS and Sensors pages.
int j = 0;
p->page_order[j++] = NodePrefs::HPB_CLOCK + 1;
p->page_order[j++] = NodePrefs::HPB_FAVOURITES + 1;
p->page_order[j++] = NodePrefs::HPB_RECENT + 1;
p->page_order[j++] = NodePrefs::HPB_RADIO + 1;
p->page_order[j++] = NodePrefs::HPB_BLUETOOTH + 1;
p->page_order[j++] = NodePrefs::HPB_ADVERT + 1;
#if ENV_INCLUDE_GPS == 1
p->page_order[j++] = NodePrefs::HPB_GPS + 1;
#endif
#if UI_SENSORS_PAGE == 1
p->page_order[j++] = NodePrefs::HPB_SENSORS + 1;
#endif
p->page_order[j++] = NodePrefs::HPB_SETTINGS + 1;
p->page_order[j++] = NodePrefs::HPB_MAP + 1;
p->page_order[j++] = NodePrefs::HPB_TOOLS + 1;
p->page_order[j++] = NodePrefs::HPB_QUICK_MSG + 1;
p->page_order[j++] = NodePrefs::HPB_SHUTDOWN + 1;
while (j < NodePrefs::PAGE_ORDER_LEN) p->page_order[j++] = 0;
p->page_order_set = NodePrefs::PAGE_ORDER_MAGIC;
}
// Swaps item's page_order slot with its neighbour in the given direction (-1=earlier, +1=later).
void movePageInOrder(int item, int delta, NodePrefs* p) {
ensurePageOrderInit(p);
int bit = homePageBitIndex(item);
if (bit < 0) return;
int cur = -1, total = 0;
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = p->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
if ((int)(v - 1) == bit) cur = i;
total++;
}
if (cur < 0) return;
int next = cur + delta;
if (next < 0 || next >= total) return;
uint8_t tmp = p->page_order[cur];
p->page_order[cur] = p->page_order[next];
p->page_order[next] = tmp;
}
bool isMsgSlot(int item) const {
return item >= MSG_SLOT_0 && item <= MSG_SLOT_9;
}
int msgSlotIndex(int item) const {
return item - MSG_SLOT_0;
}
// Returns 0, or the ms until the selected row's value should next redraw
// to keep a marquee animation going (see DisplayDriver::drawTextEllipsized).
int renderItem(DisplayDriver& display, int item, int y, bool sel) {
NodePrefs* p = _task->getNodePrefs();
int mq = 0;
char buf[24];
auto val = [&](const char* label, const char* v) { mq = info::valueRow(display, y, label, v, sel, _reserve); };
auto sw = [&](const char* label, bool on) { info::switchRow(display, y, label, on, sel, _reserve); };
drawRowSelection(display, y, sel, _reserve);
display.setCursor(2, y);
if (item >= SCHEMA_ITEM) {
mq = renderSchema(display, settings::ALL[item - SCHEMA_ITEM], p, y, sel);
} else if (item == BUZZER) {
#ifdef PIN_BUZZER
static const char* labels[] = { "On", "Off", "Auto" };
int m = _task->getBuzzerMode();
val("Buzzer", labels[m < 3 ? m : 0]);
#else
val("Buzzer", "N/A");
#endif
} else if (isHomePage(item)) {
if (p) ensurePageOrderInit(p);
int pos = homePagePosition(item, p);
char label[24];
if (pos > 0) snprintf(label, sizeof(label), "%2d %s", pos, homePageLabel(item));
else snprintf(label, sizeof(label), "%s", homePageLabel(item));
if (!homePageToggleable(item)) val(label, "always");
else sw(label, homePageVisible(item, p));
} else if (item == TX_POWER) {
snprintf(buf, sizeof(buf), "%d dBm", p ? p->tx_power_dbm : 0);
val("TX Pwr", buf);
} else if (item == RADIO_PRESET) {
val("Preset", p ? _picker.currentName(p, radioTarget(p)) : "Custom");
} else if (item == CUSTOM_FREQ) {
if (sel && _editor.active()) { // the digit editor where the value stands
display.print("Freq");
_editor.render(display, display.width() - _reserve - 2 - display.getTextWidth("000.000"), y);
} else {
snprintf(buf, sizeof(buf), "%.3f", p ? p->freq : 0.0f);
val("Freq", buf);
}
} else if (item == CUSTOM_SF) {
snprintf(buf, sizeof(buf), "%d", p ? (int)p->sf : 0);
val("SF", buf);
} else if (item == CUSTOM_BW) {
snprintf(buf, sizeof(buf), "%.1f kHz", p ? p->bw : 0.0f);
val("BW", buf);
} else if (item == CUSTOM_CR) {
snprintf(buf, sizeof(buf), "%d", p ? (int)p->cr : 0);
val("CR", buf);
#if FEAT_RX_POWERSAVE
} else if (item == POWER_SAVE) {
// Forced off (and locked) while the repeater is on — it must hear all traffic.
if (p && p->client_repeat) val("Pwr save", "--");
else sw("Pwr save", p && p->rx_powersave);
#endif
} else if (item == TX_APC) {
// Suppressed (and locked) while repeating — a repeater holds full TX power.
if (p && p->client_repeat) val("Auto pwr", "--");
else sw("Auto pwr", p && p->tx_apc);
} else if (item == SCOPE_NAME) {
const ScopeList& sl = the_mesh.scopeList();
val("Scope", sl.name(sl.default_idx));
} else if (item == LOCK_PIN) {
sw("Lock PIN", _task->passwordLockEnabled());
} else if (item == DEVICE_NAME) {
val("Name", the_mesh.getNodeName());
} else if (item == BATT_CURVE) {
val("Batt curve", p && battery::validCurve(p->batt_curve_mv) ? "Custom" : "LiPo");
} else if (item == REBOOT) {
display.print("Reboot"); // action row: Enter reboots this device
} else if (item == FACTORY_RESET) {
display.print("Factory reset"); // action row: asks twice, then erases everything
} else if (item == OWN_POSITION) {
int32_t lat, lon;
if (_task->core().gpsEnabled()) val("Position", "GPS");
else if (_task->core().fixedPosition(lat, lon)) {
snprintf(buf, sizeof(buf), "%.4f,%.4f", lat / 1e6, lon / 1e6);
val("Position", buf);
} else val("Position", "None");
#ifdef BLE_PIN_CODE
} else if (item == BLE_PIN) {
if (p && p->ble_pin) snprintf(buf, sizeof(buf), "%06lu", (unsigned long)p->ble_pin);
val("BT PIN", p && p->ble_pin ? buf : "Random");
#endif
} else if (item == KEYBOARD_TYPE) {
val("Type", (p && p->keyboard_type) ? "T9" : "ABC");
} else if (item == KEYBOARD_MAIN_ALPHABET) {
val("Main", NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_main_alphabet : 0));
} else if (item == KEYBOARD_ALPHABET) {
val("Additional", NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_alt_alphabet : 0));
#if defined(CARDKB_I2C)
} else if (item == KEYBOARD_CARDKB_COMPACT) {
val("Ext. KB", (p && p->keyboard_cardkb_compact) ? "Compact" : "Full");
#endif
#if FEAT_DISPLAY_ROTATION_SETTING
} else if (item == ROTATION) {
val("Rotation", rotLabel(p ? p->display_rotation - DISPLAY_ROTATION : 0));
#endif
#if FEAT_JOYSTICK_ROTATION_SETTING
} else if (item == JOY_ROTATION) {
val("Joystick", rotLabel(p ? p->joystick_rotation : 0));
#endif
#if FEAT_FULL_REFRESH_SETTING
} else if (item == EINK_FULL_REFRESH) {
uint8_t idx = p ? p->eink_full_refresh_every : 0;
if (idx >= EINK_FULL_REFRESH_COUNT) idx = 0;
val("Full rfsh", EINK_FULL_REFRESH_LABELS[idx]);
#endif
} else if (item == DM_FILTER) {
val("DMs", (p && p->dm_show_all) ? "All" : "Fav");
} else if (item == CH_FILTER) {
val("Channels", (p && p->ch_fav_only) ? "Fav" : "All");
} else if (item == ROOM_FILTER) {
val("Rooms", (p && p->room_fav_only) ? "Fav" : "All");
} else if (item == PRUNE_NOW) {
display.print("Prune now"); // action row: Enter counts + confirms + removes
} else if (isMsgSlot(item)) {
// A message template reads from the left, cut at the edge (scrolls when selected).
int slot = msgSlotIndex(item);
char label[5];
snprintf(label, sizeof(label), "Q%d:", slot + 1);
display.print(label);
const char* tmpl = (p && p->custom_msgs[slot][0]) ? p->custom_msgs[slot] : "(empty)";
int xm = 8 + display.getCharWidth() * 4;
int r = display.drawTextEllipsized(xm, y, display.width() - xm - _reserve - 2, tmpl, sel);
if (sel && r > 0) mq = r;
}
return mq;
}
// 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_lock_pass = false; // setting the screen PIN via the keyboard
char _lock_pass_first[KeyboardWidget::PIN_MAX_LEN + 1] = ""; // the first entry, awaiting its repeat
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;
// Contacts > "Prune now" confirm -- a separate PopupMenu from
// _scope_action_menu since this one pops up directly over the flat
// Settings list (PRUNE_NOW's own row), not nested inside a sub-screen.
PopupMenu _prune_confirm;
char _prune_confirm_title[40];
// Privacy > Position: Type in / From waypoint / Clear, then the waypoints;
// the keyboard for typing one in. Privacy > BT PIN: the number pad.
PopupMenu _pos_menu;
bool _pos_pick_wp = false; // _pos_menu lists the waypoints
bool _edit_pos = false;
bool _edit_ble_pin = false;
// System > Factory reset: asked twice (1, 2), 0 = not asking.
PopupMenu _reset_confirm;
uint8_t _reset_stage = 0;
int renderScopeMgmt(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
drawScreenHeader(display, "Scope", -1, 0, 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
// _kb is open to name a new preset, not a message slot.
RadioPresetPicker _picker;
// Manual radio-parameter editing (digit-by-digit Freq editor + SF/BW/CR
// stepping), shared with Tools › Repeater — see RadioParamsEditor.h.
RadioParamsEditor _editor;
public:
SettingsScreen(UITask* task, KeyboardWidget* kb)
: _task(task), _kb(kb) {
buildSections();
resetList();
}
void onShow() override {
const bool keep = _keep_place; // back from a screen opened here: same row
_keep_place = false;
_task->savePrefsIfDirty(_dirty); // a change made before a screen opened from here
_edit_name = false;
_scope_mgmt_active = false;
_scope_rename_idx = -2;
_scope_action_menu.active = false;
_scope_delete_confirm_active = false;
_prune_confirm.active = false;
_pos_menu.active = false;
_reset_confirm.active = false;
_reset_stage = 0;
_edit_pos = _edit_ble_pin = false;
_edit_lock_pass = false;
_lock_pass_first[0] = '\0';
if (!keep) resetList();
_editor.freq.active = false;
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
if (_edit_slot >= 0 || _edit_name || _edit_lock_pass || _edit_pos || _edit_ble_pin || _scope_rename_idx != -2 || _picker.saving) {
return _kb->render(display);
}
if (_scope_mgmt_active) return renderScopeMgmt(display);
drawScreenHeader(display, "Settings");
int mq_delay = 0;
_acc.render(display,
// Section header: disclosure mark + name
[&](int sec, int y, bool sel, int reserve, bool collapsed) {
_reserve = reserve;
display.setColor(DisplayDriver::LIGHT);
drawRowSelection(display, y, sel, reserve);
drawDisclosure(display, 2, y, !collapsed);
display.setCursor(2 + 2 * display.getCharWidth(), y);
display.print(sectionName(_sec_header[sec]));
},
// Item row
[&](int sec, int item, int y, bool sel, int reserve) {
_reserve = reserve;
int r = renderItem(display, _sec_items[sec][item], y, sel);
if (r > 0) mq_delay = r;
});
if (_picker.menu.active) _picker.menu.render(display);
if (_prune_confirm.active) _prune_confirm.render(display);
if (_pos_menu.active) _pos_menu.render(display);
if (_reset_confirm.active) _reset_confirm.render(display);
return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000;
}
bool handleInput(char c) override {
NodePrefs* p = _task->getNodePrefs();
// Keyboard editing mode for message slots
if (_edit_slot >= 0) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
if (p) {
strncpy(p->custom_msgs[_edit_slot], _kb->buf, sizeof(p->custom_msgs[0]) - 1);
p->custom_msgs[_edit_slot][sizeof(p->custom_msgs[0]) - 1] = '\0';
_dirty = true;
}
_edit_slot = -1;
} else if (res == KeyboardWidget::CANCELLED) {
_edit_slot = -1;
}
return true;
}
// Keyboard editing mode for the device name
if (_edit_name) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
if (p) {
strncpy(p->node_name, _kb->buf, sizeof(p->node_name) - 1);
p->node_name[sizeof(p->node_name) - 1] = '\0';
_dirty = true; // savePrefsIfDirty on exit; getNodeName()/self-advert read node_name live
}
_edit_name = false;
} else if (res == KeyboardWidget::CANCELLED) {
_edit_name = false;
}
return true;
}
// Keyboard editing mode for the lock-screen password
if (_edit_lock_pass) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
// Entered twice: a typo here would lock the owner out.
if (strlen(_kb->buf) < 4) {
_kb->prompt = "Min 4";
} else if (!_lock_pass_first[0]) {
strncpy(_lock_pass_first, _kb->buf, sizeof(_lock_pass_first) - 1);
_lock_pass_first[sizeof(_lock_pass_first) - 1] = '\0';
beginPinEntry("Again");
} else if (strcmp(_lock_pass_first, _kb->buf) != 0) {
_lock_pass_first[0] = '\0';
beginPinEntry("No match");
} else {
if (p) {
_task->setNodeLockPassword(_kb->buf);
the_mesh.savePrefs(); // now, not on leaving: a PIN must survive a power-off right after
}
_edit_lock_pass = false;
_lock_pass_first[0] = '\0';
}
} else if (res == KeyboardWidget::CANCELLED) {
_edit_lock_pass = false;
_lock_pass_first[0] = '\0';
}
return true;
}
// Privacy > Position typed in: "lat, lon"
if (_edit_pos) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
int32_t lat, lon;
if (geo::parseLatLon(_kb->buf, lat, lon) && (lat || lon)) {
_task->core().setFixedPosition(lat, lon);
_edit_pos = false;
} else {
_kb->prompt = "Lat, lon?"; // stays open to fix it
}
} else if (res == KeyboardWidget::CANCELLED) {
_edit_pos = false;
}
return true;
}
// Privacy > BT PIN: six digits, or nothing for a random one
if (_edit_ble_pin) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
uint32_t pin = (uint32_t)strtoul(_kb->buf, nullptr, 10);
if ((_kb->buf[0] && strlen(_kb->buf) != 6) || !_task->core().setBlePin(pin)) {
_kb->prompt = "6 digits";
} else {
_edit_ble_pin = false;
_task->showAlert("From next start", 1200);
}
} else if (res == KeyboardWidget::CANCELLED) {
_edit_ble_pin = false;
}
return true;
}
// Keyboard editing mode for adding/renaming a scope-list entry
if (_scope_rename_idx != -2) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
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) {
_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 as default
// DMs, the relay's primary slot and every channel left on
// "Default" follow it; the list marks it [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->prompt = "Scope";
_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->prompt = "Scope";
_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 as 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; }
return true;
}
// Keyboard editing mode for naming a new saved preset
if (_picker.saving) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
if (p && _picker.save(p, _kb->buf, radioTarget(p))) {
_dirty = true;
}
_picker.saving = false;
} else if (res == KeyboardWidget::CANCELLED) {
_picker.saving = false;
}
return true;
}
// Radio preset popup (and its "delete a saved preset" sub-list)
if (_picker.menu.active) {
auto res = _picker.menu.handleInput(c);
if (res == PopupMenu::SELECTED && p) {
switch (_picker.onSelected(_picker.menu.selectedIndex(), p, radioTarget(p))) {
case RadioPresetPicker::START_SAVE:
_kb->begin("", (int)sizeof(p->user_radio_presets[0].name) - 1);
_kb->prompt = "Preset name";
_kb->clearPlaceholders(); // {loc}/{time} are for messages, not preset names
break;
case RadioPresetPicker::APPLIED:
_task->applyRadioParams();
_dirty = true;
break;
case RadioPresetPicker::DELETED:
_dirty = true;
break;
case RadioPresetPicker::NONE:
break;
}
} else if (res == PopupMenu::CANCELLED) {
_picker.deleting = false;
_picker.confirm_slot = -1;
}
return true;
}
// Privacy > Position menu, and its waypoint list
if (_pos_menu.active) {
auto res = _pos_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
int sel = _pos_menu.selectedIndex();
const WaypointModel& wp = _task->core().waypoints;
if (_pos_pick_wp) {
if (sel < wp.count()) _task->core().setFixedPosition(wp.at(sel).lat_1e6, wp.at(sel).lon_1e6);
_pos_pick_wp = false;
} else if (sel == 0) { // Type in
int32_t lat, lon;
char cur[32] = "";
if (_task->core().fixedPosition(lat, lon)) snprintf(cur, sizeof(cur), "%.5f, %.5f", lat / 1e6, lon / 1e6);
_edit_pos = true;
_kb->begin(cur, 31);
_kb->prompt = "Lat, lon";
_kb->clearPlaceholders();
} else if (sel == 1 && wp.count() > 0) { // From waypoint
_pos_menu.begin("Waypoint", 4);
for (int i = 0; i < wp.count() && i < 24; i++) _pos_menu.addItem(wp.at(i).label[0] ? wp.at(i).label : "(unnamed)");
_pos_pick_wp = true;
} else { // Clear
_task->core().setFixedPosition(0, 0);
}
} else if (res != PopupMenu::NONE) {
_pos_pick_wp = false;
}
return true;
}
// System > Factory reset: the second yes erases
if (_reset_confirm.active) {
auto res = _reset_confirm.handleInput(c);
if (res == PopupMenu::SELECTED && _reset_confirm.selectedIndex() == 0) {
if (_reset_stage == 1) {
_reset_stage = 2;
_reset_confirm.beginConfirm("Keys too. Sure?", "Erase");
return true;
}
_task->core().factoryReset(); // restarts; back here only if the erase failed
_task->showAlert("Erase failed", 1400);
}
if (res != PopupMenu::NONE) _reset_stage = 0;
return true;
}
// Contacts > "Prune now" confirm
if (_prune_confirm.active) {
auto res = _prune_confirm.handleInput(c);
if (res == PopupMenu::SELECTED && _prune_confirm.selectedIndex() == 0) { // "Remove"
int n = the_mesh.pruneStaleContacts();
char msg[24];
snprintf(msg, sizeof(msg), "Removed %d contact%s", n, n == 1 ? "" : "s");
_task->showAlert(msg, 1400);
}
return true;
}
if (c == KEY_CANCEL) {
_task->savePrefsIfDirty(_dirty);
_task->gotoHomeScreen();
return true;
}
// Up/down navigation and section fold/unfold live in the shared helper.
// Enter on an item returns ACTIVATED and falls through to the per-item logic
// below; left/right are ignored by the helper and likewise fall through.
AccordionList::Result ar = _acc.handleInput(c);
if (ar == AccordionList::HANDLED) return true;
_selected = currentItem();
bool right = keyIsNext(c);
bool left = keyIsPrev(c);
bool enter = (c == KEY_ENTER);
if (_selected >= SCHEMA_ITEM) {
if (!p || !(left || right || enter)) return false;
const settings::Setting& st = settings::ALL[_selected - SCHEMA_ITEM];
if (isBar(st)) {
const int v = settings::get(*p, st) + (left ? -1 : 1);
if (v < 0 || v >= st.count) return true;
settings::set(*p, st, (uint8_t)v);
if (st.changed) st.changed(_task->core());
} else {
settings::step(*p, st, left ? -1 : 1, _task->core());
}
_dirty = true;
return true;
}
if (_selected == BUZZER && (left || right || enter)) {
_task->cycleBuzzerMode();
_dirty = true;
return true;
}
if (isHomePage(_selected) && p) {
if (left || right) {
movePageInOrder(_selected, left ? -1 : 1, p);
_dirty = true;
return true;
}
if (enter && homePageToggleable(_selected)) {
if (!p->home_pages_mask) p->home_pages_mask = NodePrefs::HP_ALL;
// Set or clear the page's bits together (Status has three).
if (homePageVisible(_selected, p)) p->home_pages_mask &= ~homePageBit(_selected);
else p->home_pages_mask |= homePageBit(_selected);
_dirty = true;
return true;
}
return enter;
}
if (_selected == TX_POWER && p) {
if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; }
if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; }
}
if (_selected == RADIO_PRESET && p && enter) {
_picker.open(p, radioTarget(p), "Radio Preset");
return true;
}
// Enter Freq's digit-by-digit editor. Bounds come from the radio driver
// itself (RadioLib's own validated range for this chip) so a digit can never
// be nudged to a value setFrequency() would reject.
if (_selected == CUSTOM_FREQ && p && enter) {
float min_mhz, max_mhz;
radio_driver.getFreqBounds(min_mhz, max_mhz);
_editor.beginFreq(p->freq, min_mhz, max_mhz);
return true;
}
int dir = right ? 1 : (left ? -1 : 0);
if (_selected == CUSTOM_SF && p && dir && RadioParamsEditor::stepSF(p->sf, dir)) { _task->applyRadioParams(); _dirty = true; return true; }
if (_selected == CUSTOM_BW && p && dir && RadioParamsEditor::stepBW(p->bw, dir)) { _task->applyRadioParams(); _dirty = true; return true; }
if (_selected == CUSTOM_CR && p && dir && RadioParamsEditor::stepCR(p->cr, dir)) { _task->applyRadioParams(); _dirty = true; return true; }
#if FEAT_RX_POWERSAVE
if (_selected == POWER_SAVE && p && (left || right || enter)) {
if (p->client_repeat) { _task->showAlert("Off while repeating", 900); return true; }
p->rx_powersave ^= 1;
_task->applyPowerSave();
_dirty = true;
return true;
}
#endif
if (_selected == TX_APC && p && (left || right || enter)) {
if (p->client_repeat) { _task->showAlert("Off while repeating", 900); return true; }
p->tx_apc ^= 1;
_task->applyApc();
_dirty = true;
return true;
}
// Lock PIN: Enter sets one, or clears the one set
if (_selected == LOCK_PIN && p && enter) {
if (_task->passwordLockEnabled()) {
_task->setNodeLockPassword("");
the_mesh.savePrefs();
} else {
_edit_lock_pass = true;
_lock_pass_first[0] = '\0';
beginPinEntry("New PIN");
}
return true;
}
if (_selected == DEVICE_NAME && p && enter) {
_edit_name = true;
_kb->begin(the_mesh.getNodeName(), (int)sizeof(p->node_name) - 1);
_kb->prompt = "Node name";
_kb->clearPlaceholders(); // a device name is literal, not a message
return true;
}
if (_selected == SCOPE_NAME && p && enter) {
_scope_mgmt_active = true;
_scope_mgmt_sel = 0;
_scope_mgmt_scroll = 0;
return true;
}
if (_selected == BATT_CURVE && enter) {
_keep_place = true; // back from it to this row, not a folded list
_task->savePrefsIfDirty(_dirty);
_task->gotoBatteryCurve();
return true;
}
if (_selected == REBOOT && enter) {
_task->showAlert("Rebooting...", 800);
_task->shutdown(true); // flushes prefs/RTC/contacts/trail, then reboots -- single choke point
return true;
}
if (_selected == FACTORY_RESET && enter) {
_reset_stage = 1;
_reset_confirm.beginConfirm("Erase everything?", "Erase");
return true;
}
if (_selected == OWN_POSITION && enter) {
if (_task->core().gpsEnabled()) { _task->showAlert("GPS sets it", 1200); return true; }
int32_t lat, lon;
bool have = _task->core().fixedPosition(lat, lon);
bool wps = _task->core().waypoints.count() > 0;
_pos_menu.begin("Position", 3);
_pos_menu.addItem("Type in");
if (wps) _pos_menu.addItem("From waypoint");
if (have) _pos_menu.addItem("Clear");
_pos_pick_wp = false;
return true;
}
#ifdef BLE_PIN_CODE
if (_selected == BLE_PIN && enter) {
_edit_ble_pin = true;
_kb->beginPin("", 6, false, "6 digits, or none");
_kb->clearPlaceholders();
return true;
}
#endif
if (_selected == KEYBOARD_TYPE && p && (left || right || enter)) {
p->keyboard_type ^= 1;
_dirty = true;
return true;
}
if (_selected == KEYBOARD_MAIN_ALPHABET && p && (left || right || enter)) {
int idx = p->keyboard_main_alphabet;
if (right || enter) idx = (idx + 1) % NodePrefs::KB_ALPHABET_COUNT;
else if (left) idx = (idx + NodePrefs::KB_ALPHABET_COUNT - 1) % NodePrefs::KB_ALPHABET_COUNT;
p->keyboard_main_alphabet = (uint8_t)idx;
_dirty = true;
return true;
}
if (_selected == KEYBOARD_ALPHABET && p && (left || right || enter)) {
int idx = p->keyboard_alt_alphabet;
if (right || enter) idx = (idx + 1) % NodePrefs::KB_ALPHABET_COUNT;
else if (left) idx = (idx + NodePrefs::KB_ALPHABET_COUNT - 1) % NodePrefs::KB_ALPHABET_COUNT;
p->keyboard_alt_alphabet = (uint8_t)idx;
_dirty = true;
return true;
}
#if defined(CARDKB_I2C)
if (_selected == KEYBOARD_CARDKB_COMPACT && p && (left || right || enter)) {
p->keyboard_cardkb_compact ^= 1;
_dirty = true;
return true;
}
#endif
#if FEAT_DISPLAY_ROTATION_SETTING
if (_selected == ROTATION && p && (left || right || enter)) {
p->display_rotation ^= 2; // the build's shape, either way up
_task->applyRotation();
_dirty = true;
return true;
}
#endif
#if FEAT_JOYSTICK_ROTATION_SETTING
if (_selected == JOY_ROTATION && p && (left || right || enter)) {
p->joystick_rotation = (p->joystick_rotation + (left ? 3 : 1)) & 3;
_dirty = true;
return true;
}
#endif
#if FEAT_FULL_REFRESH_SETTING
if (_selected == EINK_FULL_REFRESH && p && (left || right || enter)) {
int idx = p->eink_full_refresh_every;
if (idx >= EINK_FULL_REFRESH_COUNT) idx = 0;
idx = (idx + (left ? EINK_FULL_REFRESH_COUNT - 1 : 1)) % EINK_FULL_REFRESH_COUNT;
p->eink_full_refresh_every = idx;
_task->applyFullRefreshInterval();
_dirty = true;
return true;
}
#endif
if (_selected == DM_FILTER && p && (left || right || enter)) {
p->dm_show_all = p->dm_show_all ? 0 : 1;
_dirty = true;
return true;
}
if (_selected == CH_FILTER && p && (left || right || enter)) {
p->ch_fav_only = p->ch_fav_only ? 0 : 1;
_dirty = true;
return true;
}
if (_selected == ROOM_FILTER && p && (left || right || enter)) {
p->room_fav_only = p->room_fav_only ? 0 : 1;
_dirty = true;
return true;
}
if (_selected == PRUNE_NOW && enter) {
int n = the_mesh.countStaleContacts();
if (n == 0) {
// Two different "nothing happened" reasons, told apart so the row
// doesn't look broken when the threshold simply isn't set yet.
_task->showAlert(p && p->contact_expiry_idx == 0 ? "Expire is Off" : "No inactive contacts", 1400);
} else {
snprintf(_prune_confirm_title, sizeof(_prune_confirm_title), "Remove %d contact%s?", n, n == 1 ? "" : "s");
_prune_confirm.beginConfirm(_prune_confirm_title, "Remove");
}
return true;
}
if (isMsgSlot(_selected) && enter) {
int slot = msgSlotIndex(_selected);
_edit_slot = slot;
// Bound to the custom_msgs store (140 B) so the wider keyboard buffer
// can't overflow it on save.
_kb->begin(p ? p->custom_msgs[slot] : "",
p ? (int)sizeof(p->custom_msgs[slot]) - 1 : KB_MAX_LEN);
_kb->prompt = "Quick msg";
kbAddSensorPlaceholders(*_kb, &sensors);
return true;
}
return false;
}
};
#if FEAT_FULL_REFRESH_SETTING
const char* SettingsScreen::EINK_FULL_REFRESH_LABELS[5] = { "Off", "5", "10", "20", "30" };
#endif