Extract RingtoneEditorScreen, BotScreen, ToolsScreen to separate headers

Move three custom screens out of UITask.cpp into their own .h files
(RingtoneEditorScreen.h, BotScreen.h, ToolsScreen.h) and replace them
with three #include lines. UITask.cpp now only contains screens that
overlap with upstream (SplashScreen, SettingsScreen, QuickMsgScreen,
HomeScreen), making future upstream merges easier — custom screens live
in files that upstream will never touch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-12 14:45:02 +02:00
parent dcccc9733b
commit d51d8c0a75
4 changed files with 736 additions and 725 deletions

View File

@@ -0,0 +1,363 @@
#pragma once
// Custom screen — not part of upstream UITask.cpp
// Included by UITask.cpp after the global KB_* constants are defined.
class BotScreen : public UIScreen {
UITask* _task;
NodePrefs* _prefs;
static const int ITEM_COUNT = 4;
static const int ITEM_H = 11;
static const int START_Y = 12;
static const int VAL_X = 70;
// keyboard state (reused for trigger and reply fields)
int _kb_field; // -1=off, 2=trigger, 3=reply
char _kb_buf[KB_MAX_LEN + 1];
int _kb_len;
int _kb_maxlen;
int _kb_row, _kb_col;
bool _kb_caps;
bool _kb_ph_mode;
int _kb_ph_sel;
int _sel;
// channel + DM contact caches (refreshed on enter)
static const int MAX_BOT_DM = 16;
int _num_channels;
uint8_t _channel_indices[MAX_GROUP_CHANNELS];
int _num_dm;
uint8_t _dm_pubkeys[MAX_BOT_DM][4];
char _dm_names[MAX_BOT_DM][32];
void refreshChannels() {
_num_channels = 0;
ChannelDetails ch;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0')
_channel_indices[_num_channels++] = (uint8_t)i;
}
}
void refreshContacts() {
_num_dm = 0;
ContactInfo ci;
int n = the_mesh.getNumContacts();
for (int i = 0; i < n && _num_dm < MAX_BOT_DM; i++) {
if (!the_mesh.getContactByIdx(i, ci)) continue;
if (ci.type != ADV_TYPE_CHAT) continue;
if (!(ci.flags & 0x01)) continue; // favourites only
memcpy(_dm_pubkeys[_num_dm], ci.id.pub_key, 4);
strncpy(_dm_names[_num_dm], ci.name, sizeof(_dm_names[0]) - 1);
_dm_names[_num_dm][sizeof(_dm_names[0]) - 1] = '\0';
_num_dm++;
}
}
// returns combined index in [channels..., DM contacts...]
int currentTargetIdx() const {
if (_prefs->bot_target_type == 0) {
for (int i = 0; i < _num_channels; i++)
if (_channel_indices[i] == _prefs->bot_channel_idx) return i;
return 0;
} else {
for (int i = 0; i < _num_dm; i++)
if (memcmp(_dm_pubkeys[i], _prefs->bot_dm_pubkey, 4) == 0)
return _num_channels + i;
return _num_channels; // fallback: first DM
}
}
public:
BotScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() {
_sel = 0;
_kb_field = -1;
refreshChannels();
refreshContacts();
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
if (_kb_field >= 0) {
// keyboard mode — uses same layout as global keyboard
const char* label = (_kb_field == 2) ? "Trigger:" : "Reply:";
const char* disp_start = _kb_buf;
int disp_len = _kb_len;
if (disp_len > 20) { disp_start = _kb_buf + (disp_len - 20); disp_len = 20; }
char preview[28];
snprintf(preview, sizeof(preview), "%s%.*s_", label, disp_len, disp_start);
display.setCursor(0, KB_TEXT_Y);
display.print(preview);
display.fillRect(0, KB_SEP_Y, display.width(), 1);
// char rows
for (int row = 0; row < KB_ROWS_CHAR; row++) {
int y = KB_CHARS_Y + row * KB_CELL_H;
for (int col = 0; col < KB_COLS_CHAR; col++) {
bool sel = (_kb_row == row && _kb_col == col);
char ch = KB_CHARS[row][col];
if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A';
char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' };
int cx = col * KB_CELL_W;
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(cx, y - 1, KB_CELL_W - 1, KB_CELL_H);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(cx + 3, y);
display.print(ch_buf);
}
}
// special row: [^] [Sp] [Del] [{}] [OK] — 5 buttons at 25px each
const char* spec[] = { "[^]", "[Sp]", "[Del]", "[{}]", "[OK]" };
for (int i = 0; i < KB_SPECIAL; i++) {
bool sel = (_kb_row == KB_ROWS_CHAR && _kb_col == i);
bool active = (i == 0 && _kb_caps);
int sx = i * 25;
if (sel || active) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(sx, KB_SPECIAL_Y - 1, 24, KB_CELL_H);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(sx + 1, KB_SPECIAL_Y);
display.print(spec[i]);
display.setColor(DisplayDriver::LIGHT);
}
// placeholder picker overlay
if (_kb_ph_mode) {
display.setColor(DisplayDriver::DARK);
display.fillRect(20, 20, 88, 12 + KB_PH_COUNT * 10);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(20, 20, 88, 12 + KB_PH_COUNT * 10);
display.setCursor(24, 21);
display.print("Placeholder:");
display.fillRect(20, 30, 88, 1);
for (int i = 0; i < KB_PH_COUNT; i++) {
int py = 33 + i * 10;
if (i == _kb_ph_sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(21, py - 1, 86, 10);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(24, py);
display.print(KB_PH_LIST[i]);
}
display.setColor(DisplayDriver::LIGHT);
}
return 50;
}
display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT");
display.fillRect(0, 10, display.width(), 1);
static const char* labels[] = { "Enable", NULL, "Trigger", "Reply" };
for (int i = 0; i < ITEM_COUNT; i++) {
int y = START_Y + i * ITEM_H;
bool sel = (i == _sel);
if (sel) {
display.fillRect(0, y - 1, display.width(), ITEM_H);
display.setColor(DisplayDriver::DARK);
}
display.setCursor(2, y);
if (i == 1)
display.print(_prefs->bot_target_type == 0 ? "Channel" : "Contact");
else
display.print(labels[i]);
display.setCursor(VAL_X, y);
if (i == 0) {
display.print(_prefs->bot_enabled ? "ON" : "OFF");
} else if (i == 1) {
int total = _num_channels + _num_dm;
if (total == 0) {
display.print("none");
} else if (_prefs->bot_target_type == 0) {
ChannelDetails ch;
if (_num_channels > 0 && the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0])
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name);
else
display.print("?");
} else {
bool found = false;
for (int j = 0; j < _num_dm && !found; j++) {
if (memcmp(_dm_pubkeys[j], _prefs->bot_dm_pubkey, 4) == 0) {
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, _dm_names[j]);
found = true;
}
}
if (!found) display.print("?");
}
} else if (i == 2) {
const char* tr = _prefs->bot_trigger;
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, tr[0] ? tr : "(none)");
} else {
const char* rp = _prefs->bot_reply;
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)");
}
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, 54);
display.print("ENT:edit CANCEL:save");
return 300;
}
bool handleInput(char c) override {
bool up = (c == KEY_UP);
bool down = (c == KEY_DOWN);
bool left = (c == KEY_LEFT || c == KEY_PREV);
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
bool enter = (c == KEY_ENTER);
bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU);
if (_kb_field >= 0) {
// placeholder picker overlay takes priority
if (_kb_ph_mode) {
if (c == KEY_UP && _kb_ph_sel > 0) { _kb_ph_sel--; return true; }
if (c == KEY_DOWN && _kb_ph_sel < KB_PH_COUNT - 1) { _kb_ph_sel++; return true; }
if (c == KEY_ENTER) {
const char* ph = KB_PH_LIST[_kb_ph_sel];
int ph_len = strlen(ph);
if (_kb_len + ph_len <= _kb_maxlen) {
memcpy(_kb_buf + _kb_len, ph, ph_len);
_kb_len += ph_len;
_kb_buf[_kb_len] = '\0';
}
_kb_ph_mode = false;
return true;
}
if (cancel) { _kb_ph_mode = false; return true; }
return true;
}
if (cancel) { _kb_field = -1; return true; }
if (up) {
if (_kb_row > 0) {
_kb_row--;
if (_kb_row == KB_ROWS_CHAR - 1) // leaving special row upward
_kb_col = _kb_col * KB_COLS_CHAR / KB_SPECIAL;
}
return true;
}
if (down) {
if (_kb_row < KB_ROWS_CHAR) {
_kb_row++;
if (_kb_row == KB_ROWS_CHAR) // entering special row
_kb_col = _kb_col * KB_SPECIAL / KB_COLS_CHAR;
}
return true;
}
if (left) {
if (_kb_col > 0) _kb_col--;
return true;
}
if (right) {
int max_col = (_kb_row == KB_ROWS_CHAR) ? KB_SPECIAL - 1 : KB_COLS_CHAR - 1;
if (_kb_col < max_col) _kb_col++;
return true;
}
if (enter) {
if (_kb_row < KB_ROWS_CHAR) {
if (_kb_len < _kb_maxlen) {
char ch = KB_CHARS[_kb_row][_kb_col];
if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A';
_kb_buf[_kb_len++] = ch;
_kb_buf[_kb_len] = '\0';
}
} else {
switch (_kb_col) {
case 0: _kb_caps = !_kb_caps; break;
case 1:
if (_kb_len < _kb_maxlen) { _kb_buf[_kb_len++] = ' '; _kb_buf[_kb_len] = '\0'; }
break;
case 2:
if (_kb_len > 0) _kb_buf[--_kb_len] = '\0';
break;
case 3:
_kb_ph_mode = true;
_kb_ph_sel = 0;
break;
case 4:
// OK — commit to prefs
if (_kb_field == 2) {
strncpy(_prefs->bot_trigger, _kb_buf, sizeof(_prefs->bot_trigger) - 1);
_prefs->bot_trigger[sizeof(_prefs->bot_trigger) - 1] = '\0';
} else {
strncpy(_prefs->bot_reply, _kb_buf, sizeof(_prefs->bot_reply) - 1);
_prefs->bot_reply[sizeof(_prefs->bot_reply) - 1] = '\0';
}
_kb_field = -1;
break;
}
}
return true;
}
return true;
}
if (cancel) {
the_mesh.savePrefs();
_task->gotoToolsScreen();
return true;
}
if (up && _sel > 0) { _sel--; return true; }
if (down && _sel < ITEM_COUNT - 1) { _sel++; return true; }
if (_sel == 0 && (enter || left || right)) {
_prefs->bot_enabled ^= 1;
return true;
}
if (_sel == 1) {
int total = _num_channels + _num_dm;
if (total == 0) return false;
int idx = currentTargetIdx();
if (right || enter) idx = (idx + 1) % total;
if (left) idx = (idx + total - 1) % total;
if (left || right || enter) {
if (idx < _num_channels) {
_prefs->bot_target_type = 0;
_prefs->bot_channel_idx = _channel_indices[idx];
} else {
int di = idx - _num_channels;
_prefs->bot_target_type = 1;
memcpy(_prefs->bot_dm_pubkey, _dm_pubkeys[di], 4);
}
return true;
}
}
if ((_sel == 2 || _sel == 3) && enter) {
_kb_field = _sel;
_kb_row = 0;
_kb_col = 0;
_kb_caps = false;
_kb_ph_mode = false;
_kb_ph_sel = 0;
if (_sel == 2) {
strncpy(_kb_buf, _prefs->bot_trigger, sizeof(_kb_buf) - 1);
_kb_maxlen = sizeof(_prefs->bot_trigger) - 1;
} else {
strncpy(_kb_buf, _prefs->bot_reply, sizeof(_kb_buf) - 1);
_kb_maxlen = sizeof(_prefs->bot_reply) - 1;
}
_kb_buf[sizeof(_kb_buf) - 1] = '\0';
_kb_len = strlen(_kb_buf);
return true;
}
return false;
}
};

View File

@@ -0,0 +1,318 @@
#pragma once
// Custom screen — not part of upstream UITask.cpp
// Included by UITask.cpp after the global KB_* constants are defined.
class RingtoneEditorScreen : public UIScreen {
UITask* _task;
NodePrefs* _prefs;
static const int MAX_NOTES = 32;
static const int VISIBLE_NOTES = 7;
static const int CELL_W = 18;
static const int NOTES_Y = 12;
static const int CELL_H = 14;
static const int MENU_VISIBLE = 5;
static const uint16_t BPM_OPTS[5];
static const uint8_t DUR_VALS[4];
static const char* DUR_LABELS[4];
static const char PITCH_NAMES[8]; // lowercase rtttl names
uint8_t _notes[MAX_NOTES];
uint8_t _len;
uint8_t _bpm_idx;
int _cursor;
int _scroll;
bool _menu_open;
int _menu_sel;
int _menu_scroll;
enum MenuOpt {
M_PLAY_STOP, M_DURATION, M_BPM_UP, M_BPM_DOWN,
M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT
};
static const char* MENU_LABELS[M_COUNT];
static uint8_t notePitch(uint8_t b) { return b & 0x07; }
static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; }
static uint8_t noteDurIdx(uint8_t b) { return (b >> 5) & 0x03; }
static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) {
return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur_idx & 0x03) << 5);
}
void clampScroll() {
if (_cursor < _scroll) _scroll = _cursor;
if (_cursor >= _scroll + VISIBLE_NOTES) _scroll = _cursor - VISIBLE_NOTES + 1;
if (_scroll < 0) _scroll = 0;
}
void buildRTTTL(char* buf, int buf_len) {
uint16_t bpm = BPM_OPTS[_bpm_idx < 5 ? _bpm_idx : 2];
int pos = snprintf(buf, buf_len, "Ring:d=8,o=5,b=%u:", bpm);
for (int i = 0; i < _len && pos < buf_len - 8; i++) {
if (i > 0 && pos < buf_len - 1) buf[pos++] = ',';
uint8_t pitch = notePitch(_notes[i]);
uint8_t octave = noteOctave(_notes[i]);
uint8_t dur_val = DUR_VALS[noteDurIdx(_notes[i])];
if (pitch == 0)
pos += snprintf(buf + pos, buf_len - pos, "%dp", dur_val);
else
pos += snprintf(buf + pos, buf_len - pos, "%d%c%d", dur_val, PITCH_NAMES[pitch], octave);
}
if (pos < buf_len) buf[pos] = '\0';
}
void previewNote(uint8_t note_byte) {
uint8_t pitch = notePitch(note_byte);
if (pitch == 0) { _task->stopMelody(); return; }
char buf[28];
snprintf(buf, sizeof(buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte));
_task->playMelody(buf);
}
public:
RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() {
_bpm_idx = (_prefs && _prefs->ringtone_bpm_idx < 5) ? _prefs->ringtone_bpm_idx : 2;
_len = (_prefs && _prefs->ringtone_len <= (uint8_t)MAX_NOTES) ? _prefs->ringtone_len : 0;
if (_prefs) memcpy(_notes, _prefs->ringtone_notes, sizeof(_notes));
_cursor = 0;
_scroll = 0;
_menu_open = false;
_menu_sel = 0;
_menu_scroll = 0;
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
char hdr[32];
snprintf(hdr, sizeof(hdr), "BPM:%u %d/%d", BPM_OPTS[_bpm_idx], _len, MAX_NOTES);
display.setCursor(0, 0);
display.print(hdr);
display.fillRect(0, 10, display.width(), 1);
for (int i = 0; i < VISIBLE_NOTES; i++) {
int ni = _scroll + i;
int cx = i * CELL_W;
bool sel = (ni == _cursor);
if (ni < _len) {
uint8_t pitch = notePitch(_notes[ni]);
uint8_t octave = noteOctave(_notes[ni]);
char label[3];
if (pitch == 0) {
label[0] = '-'; label[1] = '-'; label[2] = '\0';
} else {
label[0] = PITCH_NAMES[pitch] - 32; // uppercase
label[1] = '0' + octave;
label[2] = '\0';
}
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
}
display.setCursor(cx + 3, NOTES_Y + 3);
display.print(label);
display.setColor(DisplayDriver::LIGHT);
} else if (ni == _len && _len < MAX_NOTES) {
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(cx + 6, NOTES_Y + 3);
display.print("+");
display.setColor(DisplayDriver::LIGHT);
}
}
int info_y = NOTES_Y + CELL_H + 2;
if (_scroll > 0) { display.setCursor(0, info_y); display.print("<"); }
if (_scroll + VISIBLE_NOTES <= _len) { display.setCursor(display.width() - 6, info_y); display.print(">"); }
if (_cursor < _len) {
char info[24];
snprintf(info, sizeof(info), "oct:%d dur:%s",
noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]);
display.setCursor(10, info_y);
display.print(info);
} else if (_cursor == _len) {
display.setCursor(10, info_y);
display.print("U/D to add note");
}
display.setCursor(0, 54);
display.print("ENT:oct MENU:opts");
if (_menu_open) {
static const int mw = 100, mh = MENU_VISIBLE * 10 + 4;
int mx = (128 - mw) / 2;
int my = (64 - mh) / 2;
display.setColor(DisplayDriver::DARK);
display.fillRect(mx, my, mw, mh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(mx, my, mw, mh);
for (int i = 0; i < MENU_VISIBLE; i++) {
int item = _menu_scroll + i;
if (item >= M_COUNT) break;
int iy = my + 2 + i * 10;
bool sel = (item == _menu_sel);
if (sel) {
display.fillRect(mx + 1, iy - 1, mw - 2, 10);
display.setColor(DisplayDriver::DARK);
}
display.setCursor(mx + 4, iy);
if (item == M_PLAY_STOP)
display.print(_task->isMelodyPlaying() ? "Stop" : "Play");
else
display.print(MENU_LABELS[item]);
display.setColor(DisplayDriver::LIGHT);
}
if (_menu_scroll > 0) { display.setCursor(mx + mw - 7, my + 2); display.print("^"); }
if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - 7, my + mh - 10); display.print("v"); }
}
return 200;
}
bool handleInput(char c) override {
bool up = (c == KEY_UP);
bool down = (c == KEY_DOWN);
bool left = (c == KEY_LEFT || c == KEY_PREV);
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
bool enter = (c == KEY_ENTER);
bool menu_key = (c == KEY_CONTEXT_MENU);
bool cancel = (c == KEY_CANCEL);
if (_menu_open) {
if (cancel) { _menu_open = false; return true; }
if (up && _menu_sel > 0) {
_menu_sel--;
if (_menu_sel < _menu_scroll) _menu_scroll = _menu_sel;
return true;
}
if (down && _menu_sel < M_COUNT - 1) {
_menu_sel++;
if (_menu_sel >= _menu_scroll + MENU_VISIBLE) _menu_scroll = _menu_sel - MENU_VISIBLE + 1;
return true;
}
if (!enter) return true;
_menu_open = false;
switch ((MenuOpt)_menu_sel) {
case M_PLAY_STOP:
if (_task->isMelodyPlaying()) {
_task->stopMelody();
} else if (_len > 0) {
char buf[220];
buildRTTTL(buf, sizeof(buf));
_task->playMelody(buf);
}
break;
case M_DURATION:
if (_cursor < _len) {
uint8_t p = notePitch(_notes[_cursor]);
uint8_t o = noteOctave(_notes[_cursor]);
uint8_t di = (noteDurIdx(_notes[_cursor]) + 1) & 0x03;
_notes[_cursor] = packNote(p, o, di);
}
break;
case M_BPM_UP: if (_bpm_idx < 4) _bpm_idx++; break;
case M_BPM_DOWN: if (_bpm_idx > 0) _bpm_idx--; break;
case M_INSERT:
if (_len < MAX_NOTES) {
int ins = (_cursor < _len) ? _cursor + 1 : _cursor;
for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1];
_notes[ins] = packNote(1, 5, 1);
_len++;
_cursor = ins;
clampScroll();
}
break;
case M_DELETE:
if (_len > 0 && _cursor < _len) {
for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1];
_len--;
if (_cursor >= _len && _len > 0) _cursor = _len - 1;
else if (_len == 0) _cursor = 0;
clampScroll();
}
break;
case M_SAVE:
if (_prefs) {
_prefs->ringtone_bpm_idx = _bpm_idx;
_prefs->ringtone_len = _len;
memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes));
the_mesh.savePrefs();
}
_task->stopMelody();
_task->gotoToolsScreen();
break;
case M_DISCARD:
_task->stopMelody();
_task->gotoToolsScreen();
break;
default: break;
}
return true;
}
if (cancel) { _task->stopMelody(); _task->gotoToolsScreen(); return true; }
if (menu_key) { _menu_open = true; _menu_sel = 0; _menu_scroll = 0; return true; }
if (left && _cursor > 0) { _cursor--; clampScroll(); return true; }
if (right) {
int max_cur = (_len < MAX_NOTES) ? _len : _len - 1;
if (_cursor < max_cur) { _cursor++; clampScroll(); return true; }
}
if ((up || down) && _cursor == _len && _len < MAX_NOTES) {
_notes[_len] = packNote(1, 5, 1);
_len++;
clampScroll();
}
if ((up || down) && _cursor < _len) {
uint8_t p = notePitch(_notes[_cursor]);
uint8_t o = noteOctave(_notes[_cursor]);
uint8_t di = noteDurIdx(_notes[_cursor]);
if (up) p = (p + 1) & 0x07;
if (down) p = (p + 7) & 0x07;
_notes[_cursor] = packNote(p, o, di);
previewNote(_notes[_cursor]);
return true;
}
if (enter && _cursor < _len) {
uint8_t p = notePitch(_notes[_cursor]);
uint8_t o = noteOctave(_notes[_cursor]);
uint8_t di = noteDurIdx(_notes[_cursor]);
if (p != 0) o = (o < 6) ? o + 1 : 4;
_notes[_cursor] = packNote(p, o, di);
previewNote(_notes[_cursor]);
return true;
}
if (enter && _cursor == _len && _len < MAX_NOTES) {
_notes[_len] = packNote(1, 5, 1);
_len++;
clampScroll();
previewNote(_notes[_cursor]);
return true;
}
return false;
}
};
const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 };
const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 };
const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" };
const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' };
const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = {
"Play/Stop", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard"
};

View File

@@ -0,0 +1,51 @@
#pragma once
// Custom screen — not part of upstream UITask.cpp
// Included by UITask.cpp just before HomeScreen.
class ToolsScreen : public UIScreen {
UITask* _task;
int _sel;
static const int ITEM_COUNT = 2;
static const char* ITEMS[ITEM_COUNT];
public:
ToolsScreen(UITask* task) : _task(task), _sel(0) {}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "TOOLS");
display.fillRect(0, 10, display.width(), 1);
for (int i = 0; i < ITEM_COUNT; i++) {
int y = 12 + i * 12;
bool sel = (i == _sel);
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), 11);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
display.print(sel ? ">" : " ");
display.setCursor(8, y);
display.print(ITEMS[i]);
}
display.setColor(DisplayDriver::LIGHT);
return 500;
}
bool handleInput(char c) override {
if (c == KEY_UP && _sel > 0) { _sel--; return true; }
if (c == KEY_DOWN && _sel < ITEM_COUNT - 1) { _sel++; return true; }
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; }
if (c == KEY_ENTER) {
if (_sel == 0) { _task->gotoRingtoneEditor(); return true; }
if (_sel == 1) { _task->gotoBotScreen(); return true; }
}
return false;
}
};
const char* ToolsScreen::ITEMS[2] = { "Ringtone Editor", "Auto-Reply Bot" };

View File

@@ -1744,731 +1744,10 @@ public:
}
};
// ── RingtoneEditorScreen ──────────────────────────────────────────────────────
class RingtoneEditorScreen : public UIScreen {
UITask* _task;
NodePrefs* _prefs;
static const int MAX_NOTES = 32;
static const int VISIBLE_NOTES = 7;
static const int CELL_W = 18;
static const int NOTES_Y = 12;
static const int CELL_H = 14;
static const int MENU_VISIBLE = 5;
static const uint16_t BPM_OPTS[5];
static const uint8_t DUR_VALS[4];
static const char* DUR_LABELS[4];
static const char PITCH_NAMES[8]; // lowercase rtttl names
uint8_t _notes[MAX_NOTES];
uint8_t _len;
uint8_t _bpm_idx;
int _cursor;
int _scroll;
bool _menu_open;
int _menu_sel;
int _menu_scroll;
enum MenuOpt {
M_PLAY_STOP, M_DURATION, M_BPM_UP, M_BPM_DOWN,
M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT
};
static const char* MENU_LABELS[M_COUNT];
static uint8_t notePitch(uint8_t b) { return b & 0x07; }
static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; }
static uint8_t noteDurIdx(uint8_t b) { return (b >> 5) & 0x03; }
static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) {
return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur_idx & 0x03) << 5);
}
void clampScroll() {
if (_cursor < _scroll) _scroll = _cursor;
if (_cursor >= _scroll + VISIBLE_NOTES) _scroll = _cursor - VISIBLE_NOTES + 1;
if (_scroll < 0) _scroll = 0;
}
void buildRTTTL(char* buf, int buf_len) {
uint16_t bpm = BPM_OPTS[_bpm_idx < 5 ? _bpm_idx : 2];
int pos = snprintf(buf, buf_len, "Ring:d=8,o=5,b=%u:", bpm);
for (int i = 0; i < _len && pos < buf_len - 8; i++) {
if (i > 0 && pos < buf_len - 1) buf[pos++] = ',';
uint8_t pitch = notePitch(_notes[i]);
uint8_t octave = noteOctave(_notes[i]);
uint8_t dur_val = DUR_VALS[noteDurIdx(_notes[i])];
if (pitch == 0)
pos += snprintf(buf + pos, buf_len - pos, "%dp", dur_val);
else
pos += snprintf(buf + pos, buf_len - pos, "%d%c%d", dur_val, PITCH_NAMES[pitch], octave);
}
if (pos < buf_len) buf[pos] = '\0';
}
void previewNote(uint8_t note_byte) {
uint8_t pitch = notePitch(note_byte);
if (pitch == 0) { _task->stopMelody(); return; }
char buf[28];
snprintf(buf, sizeof(buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte));
_task->playMelody(buf);
}
public:
RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() {
_bpm_idx = (_prefs && _prefs->ringtone_bpm_idx < 5) ? _prefs->ringtone_bpm_idx : 2;
_len = (_prefs && _prefs->ringtone_len <= (uint8_t)MAX_NOTES) ? _prefs->ringtone_len : 0;
if (_prefs) memcpy(_notes, _prefs->ringtone_notes, sizeof(_notes));
_cursor = 0;
_scroll = 0;
_menu_open = false;
_menu_sel = 0;
_menu_scroll = 0;
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
char hdr[32];
snprintf(hdr, sizeof(hdr), "BPM:%u %d/%d", BPM_OPTS[_bpm_idx], _len, MAX_NOTES);
display.setCursor(0, 0);
display.print(hdr);
display.fillRect(0, 10, display.width(), 1);
for (int i = 0; i < VISIBLE_NOTES; i++) {
int ni = _scroll + i;
int cx = i * CELL_W;
bool sel = (ni == _cursor);
if (ni < _len) {
uint8_t pitch = notePitch(_notes[ni]);
uint8_t octave = noteOctave(_notes[ni]);
char label[3];
if (pitch == 0) {
label[0] = '-'; label[1] = '-'; label[2] = '\0';
} else {
label[0] = PITCH_NAMES[pitch] - 32; // uppercase
label[1] = '0' + octave;
label[2] = '\0';
}
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
}
display.setCursor(cx + 3, NOTES_Y + 3);
display.print(label);
display.setColor(DisplayDriver::LIGHT);
} else if (ni == _len && _len < MAX_NOTES) {
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(cx + 6, NOTES_Y + 3);
display.print("+");
display.setColor(DisplayDriver::LIGHT);
}
}
int info_y = NOTES_Y + CELL_H + 2;
if (_scroll > 0) { display.setCursor(0, info_y); display.print("<"); }
if (_scroll + VISIBLE_NOTES <= _len) { display.setCursor(display.width() - 6, info_y); display.print(">"); }
if (_cursor < _len) {
char info[24];
snprintf(info, sizeof(info), "oct:%d dur:%s",
noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]);
display.setCursor(10, info_y);
display.print(info);
} else if (_cursor == _len) {
display.setCursor(10, info_y);
display.print("U/D to add note");
}
display.setCursor(0, 54);
display.print("ENT:oct MENU:opts");
if (_menu_open) {
static const int mw = 100, mh = MENU_VISIBLE * 10 + 4;
int mx = (128 - mw) / 2;
int my = (64 - mh) / 2;
display.setColor(DisplayDriver::DARK);
display.fillRect(mx, my, mw, mh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(mx, my, mw, mh);
for (int i = 0; i < MENU_VISIBLE; i++) {
int item = _menu_scroll + i;
if (item >= M_COUNT) break;
int iy = my + 2 + i * 10;
bool sel = (item == _menu_sel);
if (sel) {
display.fillRect(mx + 1, iy - 1, mw - 2, 10);
display.setColor(DisplayDriver::DARK);
}
display.setCursor(mx + 4, iy);
if (item == M_PLAY_STOP)
display.print(_task->isMelodyPlaying() ? "Stop" : "Play");
else
display.print(MENU_LABELS[item]);
display.setColor(DisplayDriver::LIGHT);
}
if (_menu_scroll > 0) { display.setCursor(mx + mw - 7, my + 2); display.print("^"); }
if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - 7, my + mh - 10); display.print("v"); }
}
return 200;
}
bool handleInput(char c) override {
bool up = (c == KEY_UP);
bool down = (c == KEY_DOWN);
bool left = (c == KEY_LEFT || c == KEY_PREV);
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
bool enter = (c == KEY_ENTER);
bool menu_key = (c == KEY_CONTEXT_MENU);
bool cancel = (c == KEY_CANCEL);
if (_menu_open) {
if (cancel) { _menu_open = false; return true; }
if (up && _menu_sel > 0) {
_menu_sel--;
if (_menu_sel < _menu_scroll) _menu_scroll = _menu_sel;
return true;
}
if (down && _menu_sel < M_COUNT - 1) {
_menu_sel++;
if (_menu_sel >= _menu_scroll + MENU_VISIBLE) _menu_scroll = _menu_sel - MENU_VISIBLE + 1;
return true;
}
if (!enter) return true;
_menu_open = false;
switch ((MenuOpt)_menu_sel) {
case M_PLAY_STOP:
if (_task->isMelodyPlaying()) {
_task->stopMelody();
} else if (_len > 0) {
char buf[220];
buildRTTTL(buf, sizeof(buf));
_task->playMelody(buf);
}
break;
case M_DURATION:
if (_cursor < _len) {
uint8_t p = notePitch(_notes[_cursor]);
uint8_t o = noteOctave(_notes[_cursor]);
uint8_t di = (noteDurIdx(_notes[_cursor]) + 1) & 0x03;
_notes[_cursor] = packNote(p, o, di);
}
break;
case M_BPM_UP: if (_bpm_idx < 4) _bpm_idx++; break;
case M_BPM_DOWN: if (_bpm_idx > 0) _bpm_idx--; break;
case M_INSERT:
if (_len < MAX_NOTES) {
int ins = (_cursor < _len) ? _cursor + 1 : _cursor;
for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1];
_notes[ins] = packNote(1, 5, 1);
_len++;
_cursor = ins;
clampScroll();
}
break;
case M_DELETE:
if (_len > 0 && _cursor < _len) {
for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1];
_len--;
if (_cursor >= _len && _len > 0) _cursor = _len - 1;
else if (_len == 0) _cursor = 0;
clampScroll();
}
break;
case M_SAVE:
if (_prefs) {
_prefs->ringtone_bpm_idx = _bpm_idx;
_prefs->ringtone_len = _len;
memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes));
the_mesh.savePrefs();
}
_task->stopMelody();
_task->gotoToolsScreen();
break;
case M_DISCARD:
_task->stopMelody();
_task->gotoToolsScreen();
break;
default: break;
}
return true;
}
if (cancel) { _task->stopMelody(); _task->gotoToolsScreen(); return true; }
if (menu_key) { _menu_open = true; _menu_sel = 0; _menu_scroll = 0; return true; }
if (left && _cursor > 0) { _cursor--; clampScroll(); return true; }
if (right) {
int max_cur = (_len < MAX_NOTES) ? _len : _len - 1;
if (_cursor < max_cur) { _cursor++; clampScroll(); return true; }
}
if ((up || down) && _cursor == _len && _len < MAX_NOTES) {
_notes[_len] = packNote(1, 5, 1);
_len++;
clampScroll();
}
if ((up || down) && _cursor < _len) {
uint8_t p = notePitch(_notes[_cursor]);
uint8_t o = noteOctave(_notes[_cursor]);
uint8_t di = noteDurIdx(_notes[_cursor]);
if (up) p = (p + 1) & 0x07;
if (down) p = (p + 7) & 0x07;
_notes[_cursor] = packNote(p, o, di);
previewNote(_notes[_cursor]);
return true;
}
if (enter && _cursor < _len) {
uint8_t p = notePitch(_notes[_cursor]);
uint8_t o = noteOctave(_notes[_cursor]);
uint8_t di = noteDurIdx(_notes[_cursor]);
if (p != 0) o = (o < 6) ? o + 1 : 4;
_notes[_cursor] = packNote(p, o, di);
previewNote(_notes[_cursor]);
return true;
}
if (enter && _cursor == _len && _len < MAX_NOTES) {
_notes[_len] = packNote(1, 5, 1);
_len++;
clampScroll();
previewNote(_notes[_cursor]);
return true;
}
return false;
}
};
const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 };
const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 };
const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" };
const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' };
const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = {
"Play/Stop", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard"
};
// ── BotScreen ─────────────────────────────────────────────────────────────────
class BotScreen : public UIScreen {
UITask* _task;
NodePrefs* _prefs;
static const int ITEM_COUNT = 4;
static const int ITEM_H = 11;
static const int START_Y = 12;
static const int VAL_X = 70;
// keyboard state (reused for trigger and reply fields)
int _kb_field; // -1=off, 2=trigger, 3=reply
char _kb_buf[KB_MAX_LEN + 1];
int _kb_len;
int _kb_maxlen;
int _kb_row, _kb_col;
bool _kb_caps;
bool _kb_ph_mode;
int _kb_ph_sel;
int _sel;
// channel + DM contact caches (refreshed on enter)
static const int MAX_BOT_DM = 16;
int _num_channels;
uint8_t _channel_indices[MAX_GROUP_CHANNELS];
int _num_dm;
uint8_t _dm_pubkeys[MAX_BOT_DM][4];
char _dm_names[MAX_BOT_DM][32];
void refreshChannels() {
_num_channels = 0;
ChannelDetails ch;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0')
_channel_indices[_num_channels++] = (uint8_t)i;
}
}
void refreshContacts() {
_num_dm = 0;
ContactInfo ci;
int n = the_mesh.getNumContacts();
for (int i = 0; i < n && _num_dm < MAX_BOT_DM; i++) {
if (!the_mesh.getContactByIdx(i, ci)) continue;
if (ci.type != ADV_TYPE_CHAT) continue;
if (!(ci.flags & 0x01)) continue; // favourites only
memcpy(_dm_pubkeys[_num_dm], ci.id.pub_key, 4);
strncpy(_dm_names[_num_dm], ci.name, sizeof(_dm_names[0]) - 1);
_dm_names[_num_dm][sizeof(_dm_names[0]) - 1] = '\0';
_num_dm++;
}
}
// returns combined index in [channels..., DM contacts...]
int currentTargetIdx() const {
if (_prefs->bot_target_type == 0) {
for (int i = 0; i < _num_channels; i++)
if (_channel_indices[i] == _prefs->bot_channel_idx) return i;
return 0;
} else {
for (int i = 0; i < _num_dm; i++)
if (memcmp(_dm_pubkeys[i], _prefs->bot_dm_pubkey, 4) == 0)
return _num_channels + i;
return _num_channels; // fallback: first DM
}
}
public:
BotScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {}
void enter() {
_sel = 0;
_kb_field = -1;
refreshChannels();
refreshContacts();
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
if (_kb_field >= 0) {
// keyboard mode — uses same layout as global keyboard
const char* label = (_kb_field == 2) ? "Trigger:" : "Reply:";
const char* disp_start = _kb_buf;
int disp_len = _kb_len;
if (disp_len > 20) { disp_start = _kb_buf + (disp_len - 20); disp_len = 20; }
char preview[28];
snprintf(preview, sizeof(preview), "%s%.*s_", label, disp_len, disp_start);
display.setCursor(0, KB_TEXT_Y);
display.print(preview);
display.fillRect(0, KB_SEP_Y, display.width(), 1);
// char rows
for (int row = 0; row < KB_ROWS_CHAR; row++) {
int y = KB_CHARS_Y + row * KB_CELL_H;
for (int col = 0; col < KB_COLS_CHAR; col++) {
bool sel = (_kb_row == row && _kb_col == col);
char ch = KB_CHARS[row][col];
if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A';
char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' };
int cx = col * KB_CELL_W;
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(cx, y - 1, KB_CELL_W - 1, KB_CELL_H);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(cx + 3, y);
display.print(ch_buf);
}
}
// special row: [^] [Sp] [Del] [{}] [OK] — 5 buttons at 25px each
const char* spec[] = { "[^]", "[Sp]", "[Del]", "[{}]", "[OK]" };
for (int i = 0; i < KB_SPECIAL; i++) {
bool sel = (_kb_row == KB_ROWS_CHAR && _kb_col == i);
bool active = (i == 0 && _kb_caps);
int sx = i * 25;
if (sel || active) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(sx, KB_SPECIAL_Y - 1, 24, KB_CELL_H);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(sx + 1, KB_SPECIAL_Y);
display.print(spec[i]);
display.setColor(DisplayDriver::LIGHT);
}
// placeholder picker overlay
if (_kb_ph_mode) {
display.setColor(DisplayDriver::DARK);
display.fillRect(20, 20, 88, 12 + KB_PH_COUNT * 10);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(20, 20, 88, 12 + KB_PH_COUNT * 10);
display.setCursor(24, 21);
display.print("Placeholder:");
display.fillRect(20, 30, 88, 1);
for (int i = 0; i < KB_PH_COUNT; i++) {
int py = 33 + i * 10;
if (i == _kb_ph_sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(21, py - 1, 86, 10);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(24, py);
display.print(KB_PH_LIST[i]);
}
display.setColor(DisplayDriver::LIGHT);
}
return 50;
}
display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT");
display.fillRect(0, 10, display.width(), 1);
static const char* labels[] = { "Enable", NULL, "Trigger", "Reply" };
for (int i = 0; i < ITEM_COUNT; i++) {
int y = START_Y + i * ITEM_H;
bool sel = (i == _sel);
if (sel) {
display.fillRect(0, y - 1, display.width(), ITEM_H);
display.setColor(DisplayDriver::DARK);
}
display.setCursor(2, y);
if (i == 1)
display.print(_prefs->bot_target_type == 0 ? "Channel" : "Contact");
else
display.print(labels[i]);
display.setCursor(VAL_X, y);
if (i == 0) {
display.print(_prefs->bot_enabled ? "ON" : "OFF");
} else if (i == 1) {
int total = _num_channels + _num_dm;
if (total == 0) {
display.print("none");
} else if (_prefs->bot_target_type == 0) {
ChannelDetails ch;
if (_num_channels > 0 && the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0])
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name);
else
display.print("?");
} else {
bool found = false;
for (int j = 0; j < _num_dm && !found; j++) {
if (memcmp(_dm_pubkeys[j], _prefs->bot_dm_pubkey, 4) == 0) {
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, _dm_names[j]);
found = true;
}
}
if (!found) display.print("?");
}
} else if (i == 2) {
const char* tr = _prefs->bot_trigger;
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, tr[0] ? tr : "(none)");
} else {
const char* rp = _prefs->bot_reply;
display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)");
}
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, 54);
display.print("ENT:edit CANCEL:save");
return 300;
}
bool handleInput(char c) override {
bool up = (c == KEY_UP);
bool down = (c == KEY_DOWN);
bool left = (c == KEY_LEFT || c == KEY_PREV);
bool right = (c == KEY_RIGHT || c == KEY_NEXT);
bool enter = (c == KEY_ENTER);
bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU);
if (_kb_field >= 0) {
// placeholder picker overlay takes priority
if (_kb_ph_mode) {
if (c == KEY_UP && _kb_ph_sel > 0) { _kb_ph_sel--; return true; }
if (c == KEY_DOWN && _kb_ph_sel < KB_PH_COUNT - 1) { _kb_ph_sel++; return true; }
if (c == KEY_ENTER) {
const char* ph = KB_PH_LIST[_kb_ph_sel];
int ph_len = strlen(ph);
if (_kb_len + ph_len <= _kb_maxlen) {
memcpy(_kb_buf + _kb_len, ph, ph_len);
_kb_len += ph_len;
_kb_buf[_kb_len] = '\0';
}
_kb_ph_mode = false;
return true;
}
if (cancel) { _kb_ph_mode = false; return true; }
return true;
}
if (cancel) { _kb_field = -1; return true; }
if (up) {
if (_kb_row > 0) {
_kb_row--;
if (_kb_row == KB_ROWS_CHAR - 1) // leaving special row upward
_kb_col = _kb_col * KB_COLS_CHAR / KB_SPECIAL;
}
return true;
}
if (down) {
if (_kb_row < KB_ROWS_CHAR) {
_kb_row++;
if (_kb_row == KB_ROWS_CHAR) // entering special row
_kb_col = _kb_col * KB_SPECIAL / KB_COLS_CHAR;
}
return true;
}
if (left) {
if (_kb_col > 0) _kb_col--;
return true;
}
if (right) {
int max_col = (_kb_row == KB_ROWS_CHAR) ? KB_SPECIAL - 1 : KB_COLS_CHAR - 1;
if (_kb_col < max_col) _kb_col++;
return true;
}
if (enter) {
if (_kb_row < KB_ROWS_CHAR) {
if (_kb_len < _kb_maxlen) {
char ch = KB_CHARS[_kb_row][_kb_col];
if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A';
_kb_buf[_kb_len++] = ch;
_kb_buf[_kb_len] = '\0';
}
} else {
switch (_kb_col) {
case 0: _kb_caps = !_kb_caps; break;
case 1:
if (_kb_len < _kb_maxlen) { _kb_buf[_kb_len++] = ' '; _kb_buf[_kb_len] = '\0'; }
break;
case 2:
if (_kb_len > 0) _kb_buf[--_kb_len] = '\0';
break;
case 3:
_kb_ph_mode = true;
_kb_ph_sel = 0;
break;
case 4:
// OK — commit to prefs
if (_kb_field == 2) {
strncpy(_prefs->bot_trigger, _kb_buf, sizeof(_prefs->bot_trigger) - 1);
_prefs->bot_trigger[sizeof(_prefs->bot_trigger) - 1] = '\0';
} else {
strncpy(_prefs->bot_reply, _kb_buf, sizeof(_prefs->bot_reply) - 1);
_prefs->bot_reply[sizeof(_prefs->bot_reply) - 1] = '\0';
}
_kb_field = -1;
break;
}
}
return true;
}
return true;
}
if (cancel) {
the_mesh.savePrefs();
_task->gotoToolsScreen();
return true;
}
if (up && _sel > 0) { _sel--; return true; }
if (down && _sel < ITEM_COUNT - 1) { _sel++; return true; }
if (_sel == 0 && (enter || left || right)) {
_prefs->bot_enabled ^= 1;
return true;
}
if (_sel == 1) {
int total = _num_channels + _num_dm;
if (total == 0) return false;
int idx = currentTargetIdx();
if (right || enter) idx = (idx + 1) % total;
if (left) idx = (idx + total - 1) % total;
if (left || right || enter) {
if (idx < _num_channels) {
_prefs->bot_target_type = 0;
_prefs->bot_channel_idx = _channel_indices[idx];
} else {
int di = idx - _num_channels;
_prefs->bot_target_type = 1;
memcpy(_prefs->bot_dm_pubkey, _dm_pubkeys[di], 4);
}
return true;
}
}
if ((_sel == 2 || _sel == 3) && enter) {
_kb_field = _sel;
_kb_row = 0;
_kb_col = 0;
_kb_caps = false;
_kb_ph_mode = false;
_kb_ph_sel = 0;
if (_sel == 2) {
strncpy(_kb_buf, _prefs->bot_trigger, sizeof(_kb_buf) - 1);
_kb_maxlen = sizeof(_prefs->bot_trigger) - 1;
} else {
strncpy(_kb_buf, _prefs->bot_reply, sizeof(_kb_buf) - 1);
_kb_maxlen = sizeof(_prefs->bot_reply) - 1;
}
_kb_buf[sizeof(_kb_buf) - 1] = '\0';
_kb_len = strlen(_kb_buf);
return true;
}
return false;
}
};
// ── ToolsScreen ───────────────────────────────────────────────────────────────
class ToolsScreen : public UIScreen {
UITask* _task;
int _sel;
static const int ITEM_COUNT = 2;
static const char* ITEMS[ITEM_COUNT];
public:
ToolsScreen(UITask* task) : _task(task), _sel(0) {}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "TOOLS");
display.fillRect(0, 10, display.width(), 1);
for (int i = 0; i < ITEM_COUNT; i++) {
int y = 12 + i * 12;
bool sel = (i == _sel);
if (sel) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), 11);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
display.print(sel ? ">" : " ");
display.setCursor(8, y);
display.print(ITEMS[i]);
}
display.setColor(DisplayDriver::LIGHT);
return 500;
}
bool handleInput(char c) override {
if (c == KEY_UP && _sel > 0) { _sel--; return true; }
if (c == KEY_DOWN && _sel < ITEM_COUNT - 1) { _sel++; return true; }
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; }
if (c == KEY_ENTER) {
if (_sel == 0) { _task->gotoRingtoneEditor(); return true; }
if (_sel == 1) { _task->gotoBotScreen(); return true; }
}
return false;
}
};
const char* ToolsScreen::ITEMS[2] = { "Ringtone Editor", "Auto-Reply Bot" };
// ── Custom screens (separate files to ease upstream merges) ───────────────────
#include "RingtoneEditorScreen.h"
#include "BotScreen.h"
#include "ToolsScreen.h"
// ── HomeScreen ────────────────────────────────────────────────────────────────
class HomeScreen : public UIScreen {