diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h new file mode 100644 index 00000000..6c833f9b --- /dev/null +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -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; + } +}; diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h new file mode 100644 index 00000000..2704042f --- /dev/null +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -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" +}; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h new file mode 100644 index 00000000..d2cf363e --- /dev/null +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -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" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 4def736f..ebfbdb0b 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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 {