#pragma once // Custom screen — not part of upstream UITask.cpp // Included by UITask.cpp after KeyboardWidget.h is defined. #include "TabBar.h" class BotScreen : public UIScreen { UITask* _task; NodePrefs* _prefs; // Categories are a circular tab bar in the header (shared geometry with // NearbyScreen's filter tabs and AdminScreen's category tabs — see // TabBar.h). LEFT/RIGHT switches tabs; UP/DOWN moves within the active // tab's rows. Because LEFT/RIGHT is fully owned by tab-switching, every // row's value is edited via Enter only — no more inline LEFT/RIGHT cycling. enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_COUNT }; static const char* TAB_LABELS[TAB_COUNT]; enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, ACTIONS_DM, TRIGGER_DM, REPLY_DM, ENABLE_CH, CHANNEL, COMMANDS_CH, ACTIONS_CH, TRIGGER_CH, REPLY_CH, ENABLE_ROOM, ROOM, COMMANDS_ROOM, ACTIONS_ROOM, TRIGGER_ROOM, REPLY_ROOM, QUIET_FROM, QUIET_TO }; struct Row { Kind kind; const char* label; }; static const int ROWS_PER_TAB[TAB_COUNT]; // Row labels drop the "DM"/"Ch"/"Room" suffix the old flat list needed — // the active tab already gives that context, freeing up value-column width. // Commands lives on each tab (not a shared "Other" toggle) so DM/channel/ // room can each independently answer !ping etc. or stay quiet. static Row tabRow(int tab, int idx) { static const Row DIRECT[] = { { ENABLE_DM, "Enable" }, { DM_SCOPE, "DM allow" }, { COMMANDS_DM, "Commands" }, { ACTIONS_DM, "Actions" }, { TRIGGER_DM, "Trigger" }, { REPLY_DM, "Reply" }, }; static const Row CHANNEL_ROWS[] = { { ENABLE_CH, "Enable" }, { CHANNEL, "Channel" }, { COMMANDS_CH, "Commands" }, { ACTIONS_CH, "Actions" }, { TRIGGER_CH, "Trigger" }, { REPLY_CH, "Reply" }, }; static const Row ROOM_ROWS[] = { { ENABLE_ROOM, "Enable" }, { ROOM, "Room" }, { COMMANDS_ROOM, "Commands" }, { ACTIONS_ROOM, "Actions" }, { TRIGGER_ROOM, "Trigger" }, { REPLY_ROOM, "Reply" }, }; static const Row OTHER[] = { { QUIET_FROM, "Quiet from" }, { QUIET_TO, "Quiet to" }, }; switch (tab) { case TAB_CHANNEL: return CHANNEL_ROWS[idx]; case TAB_ROOM: return ROOM_ROWS[idx]; case TAB_DIRECT: return DIRECT[idx]; default: return OTHER[idx]; } } uint8_t _tab; // persists across visits, like NearbyScreen's _filter int _sel; // selected row index within the active tab int _scroll; // index of first visible row (managed by drawList in render) bool _dirty; // keyboard state (reused for trigger and reply fields) int _kb_row; // -1=off, else the row index (within the active tab) being edited KeyboardWidget* _kb; // Quiet-hours stepper: Enter on Quiet from/to enters this sub-mode (LEFT/ // RIGHT is unavailable for +/- now that it switches tabs), where UP/DOWN // steps the hour and Enter/Cancel exits back to normal row browsing. int _stepper_row; // -1=off, else the row index being stepped // channel/room counts (refreshed on enter) — just gate whether Enter opens // the (possibly empty) picker; the picker itself browses the real list, so // no local index cache is needed now that there's no LEFT/RIGHT quick-cycle. int _num_channels; int _num_rooms; 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') _num_channels++; } void refreshRooms() { _num_rooms = 0; ContactInfo ci; int total = the_mesh.getNumContacts(); for (int i = 0; i < total; i++) if (the_mesh.getContactByIdx(i, ci) && ci.type == ADV_TYPE_ROOM) _num_rooms++; } // Header as a circular tab bar (shared geometry — see TabBar.h). `right_reserve` // keeps the reply counter (drawn after this call) clear of the rightmost tab. void drawTabBar(DisplayDriver& display, int right_reserve) { tabbar::draw(display, TAB_LABELS, TAB_COUNT, _tab, right_reserve); } public: BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb) : _task(task), _prefs(prefs), _tab(TAB_CHANNEL), _kb(kb) {} void onShow() override { // _tab persists across visits (like NearbyScreen's _filter) — only reset // the in-tab cursor and any open sub-mode. _sel = 0; _scroll = 0; _kb_row = -1; _stepper_row = -1; _dirty = false; refreshChannels(); refreshRooms(); } int render(DisplayDriver& display) override { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); if (_kb_row >= 0) { return _kb->render(display); } int val_x = display.valCol(); // Reply counter, right-aligned in the header — reserve its width from the // tab bar before drawing so a right-side tab can't run under it. uint16_t sent = the_mesh.botReplyCount(); char cbuf[12]; int cw = 0; if (sent > 0) { snprintf(cbuf, sizeof(cbuf), "%u", (unsigned)sent); cw = display.getTextWidth(cbuf); } drawTabBar(display, sent > 0 ? cw + 2 : 0); if (sent > 0) { display.setCursor(display.width() - cw - 1, 0); display.print(cbuf); display.setColor(DisplayDriver::LIGHT); } int n = ROWS_PER_TAB[_tab]; drawList(display, n, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { Row r = tabRow(_tab, i); drawRowSelection(display, y, sel, reserve); display.setCursor(2, y); display.print(r.label); display.setCursor(val_x, y); switch (r.kind) { case ENABLE_DM: display.print(_prefs->bot_enabled ? "ON" : "OFF"); break; case ENABLE_CH: display.print(_prefs->bot_channel_enabled ? "ON" : "OFF"); break; case ENABLE_ROOM: display.print(_prefs->bot_room_enabled ? "ON" : "OFF"); break; case DM_SCOPE: display.print(_prefs->bot_dm_scope ? "Fav" : "All"); break; case CHANNEL: { ChannelDetails ch; if (_num_channels == 0) display.print("(none)"); else if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, ch.name); else display.print("?"); break; } case ROOM: { if (_num_rooms == 0) { display.print("(none)"); } else { ContactInfo* c = the_mesh.lookupContactByPubKey(_prefs->bot_room_prefix, NodePrefs::FAVOURITE_PREFIX_LEN); if (c && c->name[0]) display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, c->name); else display.print("?"); } break; } case TRIGGER_DM: case TRIGGER_CH: case TRIGGER_ROOM: { const char* tr = (r.kind == TRIGGER_DM) ? _prefs->bot_trigger : (r.kind == TRIGGER_CH) ? _prefs->bot_trigger_ch : _prefs->bot_trigger_room; const char* shown = !tr[0] ? "(none)" : (tr[0] == '*' && !tr[1]) ? "(any msg)" // wildcard / away mode : tr; display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, shown); break; } case REPLY_DM: case REPLY_CH: case REPLY_ROOM: { const char* rp = (r.kind == REPLY_DM) ? _prefs->bot_reply_dm : (r.kind == REPLY_CH) ? _prefs->bot_reply_ch : _prefs->bot_reply_room; display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, rp[0] ? rp : "(none)"); break; } case COMMANDS_DM: display.print(_prefs->bot_commands_enabled ? "ON" : "OFF"); break; case COMMANDS_CH: display.print(_prefs->bot_commands_ch ? "ON" : "OFF"); break; case COMMANDS_ROOM: display.print(_prefs->bot_commands_room ? "ON" : "OFF"); break; case ACTIONS_DM: display.print(_prefs->bot_actions_dm ? "ON" : "OFF"); break; case ACTIONS_CH: display.print(_prefs->bot_actions_ch ? "ON" : "OFF"); break; case ACTIONS_ROOM: display.print(_prefs->bot_actions_room ? "ON" : "OFF"); break; case QUIET_FROM: case QUIET_TO: { bool off = (_prefs->bot_quiet_start == _prefs->bot_quiet_end); char hb[10]; if (off) { strcpy(hb, "OFF"); } else { uint8_t h = (r.kind == QUIET_FROM) ? _prefs->bot_quiet_start : _prefs->bot_quiet_end; snprintf(hb, sizeof(hb), "%02d:00", h); } // Bracket the value while the stepper sub-mode is open on this row, // as a visual cue that UP/DOWN now steps it instead of moving rows. if (_stepper_row == i) { char bracketed[14]; snprintf(bracketed, sizeof(bracketed), "[%s]", hb); display.print(bracketed); } else { display.print(hb); } break; } default: break; } display.setColor(DisplayDriver::LIGHT); }); return 2000; } bool handleInput(char c) override { bool up = (c == KEY_UP); bool down = (c == KEY_DOWN); bool enter = (c == KEY_ENTER); bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU); if (_kb_row >= 0) { auto res = _kb->handleInput(c); if (res == KeyboardWidget::DONE) { Kind k = tabRow(_tab, _kb_row).kind; char* dst = fieldBuf(k); int cap = fieldCap(k); if (dst) { strncpy(dst, _kb->buf, cap - 1); dst[cap - 1] = '\0'; } _dirty = true; _kb_row = -1; } else if (res == KeyboardWidget::CANCELLED) { _kb_row = -1; } return true; } if (_stepper_row >= 0) { Kind k = tabRow(_tab, _stepper_row).kind; uint8_t& h = (k == QUIET_FROM) ? _prefs->bot_quiet_start : _prefs->bot_quiet_end; if (up) { h = (h + 1) % 24; _dirty = true; return true; } if (down) { h = (h + 23) % 24; _dirty = true; return true; } if (enter || cancel) { _stepper_row = -1; return true; } return true; // swallow LEFT/RIGHT etc. while stepping } if (cancel) { _task->savePrefsIfDirty(_dirty); _task->gotoToolsScreen(); return true; } if (keyIsPrev(c)) { _tab = (_tab + TAB_COUNT - 1) % TAB_COUNT; _sel = _scroll = 0; return true; } if (keyIsNext(c)) { _tab = (_tab + 1) % TAB_COUNT; _sel = _scroll = 0; return true; } int n = ROWS_PER_TAB[_tab]; // drawList() reclamps _scroll from _sel every render. if (up) { _sel = (_sel > 0) ? _sel - 1 : n - 1; return true; } if (down) { _sel = (_sel < n - 1) ? _sel + 1 : 0; return true; } if (!enter) return false; Kind k = tabRow(_tab, _sel).kind; switch (k) { case ENABLE_DM: _prefs->bot_enabled ^= 1; _dirty = true; return true; case ENABLE_CH: _prefs->bot_channel_enabled ^= 1; _dirty = true; return true; case ENABLE_ROOM: _prefs->bot_room_enabled ^= 1; _dirty = true; return true; case DM_SCOPE: _prefs->bot_dm_scope ^= 1; _dirty = true; return true; case COMMANDS_DM: _prefs->bot_commands_enabled ^= 1; _dirty = true; return true; case COMMANDS_CH: _prefs->bot_commands_ch ^= 1; _dirty = true; return true; case COMMANDS_ROOM: _prefs->bot_commands_room ^= 1; _dirty = true; return true; case ACTIONS_DM: _prefs->bot_actions_dm ^= 1; _dirty = true; return true; case ACTIONS_CH: _prefs->bot_actions_ch ^= 1; _dirty = true; return true; case ACTIONS_ROOM: _prefs->bot_actions_room ^= 1; _dirty = true; return true; case CHANNEL: // Full browsable channel picker (same experience as Live Share's "To" // row); which channel is *active* is now the separate Enable row. if (_num_channels == 0) return false; _task->savePrefsIfDirty(_dirty); _task->pickBotChannelTarget(); return true; case ROOM: if (_num_rooms == 0) return false; _task->savePrefsIfDirty(_dirty); _task->pickBotRoomTarget(); return true; case QUIET_FROM: case QUIET_TO: _stepper_row = _sel; return true; case TRIGGER_DM: case TRIGGER_CH: case TRIGGER_ROOM: case REPLY_DM: case REPLY_CH: case REPLY_ROOM: { _kb_row = _sel; _kb->begin(fieldBuf(k), fieldCap(k) - 1); bool is_trigger = (k == TRIGGER_DM || k == TRIGGER_CH || k == TRIGGER_ROOM); if (is_trigger) { _kb->clearPlaceholders(); // trigger is literal — placeholders never match } else { kbAddSensorPlaceholders(*_kb, &sensors); // Only meaningful in a bot reply (there's an actual triggering // sender/hop-count to fill them with) — not offered on the general // compose keyboard's own placeholder list. _kb->addPlaceholder("{name}"); _kb->addPlaceholder("{hops}"); } return true; } default: return false; } } private: // Maps a keyboard-editable row kind to its backing string + capacity. char* fieldBuf(Kind k) { switch (k) { case TRIGGER_DM: return _prefs->bot_trigger; case REPLY_DM: return _prefs->bot_reply_dm; case TRIGGER_CH: return _prefs->bot_trigger_ch; case REPLY_CH: return _prefs->bot_reply_ch; case TRIGGER_ROOM: return _prefs->bot_trigger_room; case REPLY_ROOM: return _prefs->bot_reply_room; default: return nullptr; } } int fieldCap(Kind k) { switch (k) { case TRIGGER_DM: return sizeof(_prefs->bot_trigger); case REPLY_DM: return sizeof(_prefs->bot_reply_dm); case TRIGGER_CH: return sizeof(_prefs->bot_trigger_ch); case REPLY_CH: return sizeof(_prefs->bot_reply_ch); case TRIGGER_ROOM: return sizeof(_prefs->bot_trigger_room); case REPLY_ROOM: return sizeof(_prefs->bot_reply_room); default: return 0; } } }; const char* BotScreen::TAB_LABELS[BotScreen::TAB_COUNT] = { "Channel", "Room", "Direct", "Other" }; const int BotScreen::ROWS_PER_TAB[BotScreen::TAB_COUNT] = { 6, 6, 6, 2 };