Files
MeshCore-Solo/examples/companion_radio/ui-new/BotScreen.h
T
JakubandClaude Opus 5.5 1c8206a510 feat(ui-core,ui-lvgl): bot settings table in the Core, L2 bot screen
BotConfig (tabs, rows, the NodePrefs field behind each, reply placeholders)
moves into ui-core; ui-new's Tools > Bot renders it. L2: Home > Bot with
switches, list pickers, text popups with placeholder buttons, quiet hours.

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

241 lines
9.1 KiB
C++

#pragma once
// Custom screen — not part of upstream UITask.cpp
// Included by UITask.cpp after KeyboardWidget.h is defined.
#include "TabBar.h"
#include "../ui-core/BotConfig.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; every value is edited via Enter. The rows and their NodePrefs
// fields are the UI Core's table (ui-core/BotConfig.h, shared with ui-lvgl).
typedef botcfg::Kind Kind;
typedef botcfg::Row Row;
static const Row& tabRow(int tab, int idx) { return botcfg::row(tab, 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();
// +MAX_ANON_CONTACTS: getContactByIdx() indexes the raw contacts[] table,
// whose first MAX_ANON_CONTACTS slots are reserved anon-request entries
// that getNumContacts() already excludes from its count (see
// NearbyScreen.h's own contact scan, and MessagesScreen.h's
// buildContactList(), for the same offset) -- without it this underrode
// real room-server contacts by up to MAX_ANON_CONTACTS.
for (int i = 0; i < total; i++)
if (the_mesh.getContactByIdx(MAX_ANON_CONTACTS + 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, botcfg::TAB_LABELS, botcfg::TAB_COUNT, _tab, right_reserve);
}
public:
BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb)
: _task(task), _prefs(prefs), _tab(botcfg::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 = botcfg::rowCount(_tab);
int mq_delay = 0;
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);
int cap;
const char* txt = botcfg::text(_prefs, r.kind, cap);
const uint8_t* fl = botcfg::flag(_prefs, r.kind);
char name[32];
if (fl) {
display.print(r.kind == botcfg::DM_SCOPE ? (*fl ? "Fav" : "All") : (*fl ? "ON" : "OFF"));
} else if (txt) {
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve,
botcfg::shownText(txt, botcfg::isTrigger(r.kind)), sel);
if (sel && mqr > 0) mq_delay = mqr;
} else if (r.kind == botcfg::CHANNEL || r.kind == botcfg::ROOM) {
bool any = r.kind == botcfg::CHANNEL ? _num_channels > 0 : _num_rooms > 0;
bool ok = r.kind == botcfg::CHANNEL ? botcfg::channelName(_prefs, name, sizeof(name))
: botcfg::roomName(_prefs, name, sizeof(name));
if (!any) display.print("(none)");
else if (!ok) display.print("?");
else {
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, name, sel);
if (sel && mqr > 0) mq_delay = mqr;
}
} else if (botcfg::isHour(r.kind)) {
char hb[10];
if (botcfg::quietOff(_prefs)) strcpy(hb, "OFF");
else snprintf(hb, sizeof(hb), "%02d:00", botcfg::hour(_prefs, r.kind));
// 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);
}
}
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);
if (_kb_row >= 0) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
Kind k = tabRow(_tab, _kb_row).kind;
int cap = 0;
char* dst = botcfg::text(_prefs, k, cap);
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) {
uint8_t& h = botcfg::hour(_prefs, tabRow(_tab, _stepper_row).kind);
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 + botcfg::TAB_COUNT - 1) % botcfg::TAB_COUNT; _sel = _scroll = 0; return true; }
if (keyIsNext(c)) { _tab = (_tab + 1) % botcfg::TAB_COUNT; _sel = _scroll = 0; return true; }
int n = botcfg::rowCount(_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;
if (uint8_t* fl = botcfg::flag(_prefs, k)) { *fl ^= 1; _dirty = true; return true; }
int cap = 0;
if (char* txt = botcfg::text(_prefs, k, cap)) {
_kb_row = _sel;
_kb->begin(txt, cap - 1);
if (botcfg::isTrigger(k)) {
_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;
}
switch (k) {
case botcfg::CHANNEL:
// Full browsable channel picker (same experience as Live Share's "To"
// row); which channel is *active* is the separate Enable row.
if (_num_channels == 0) return false;
_task->savePrefsIfDirty(_dirty);
_task->pickBotChannelTarget();
return true;
case botcfg::ROOM:
if (_num_rooms == 0) return false;
_task->savePrefsIfDirty(_dirty);
_task->pickBotRoomTarget();
return true;
case botcfg::QUIET_FROM:
case botcfg::QUIET_TO:
_stepper_row = _sel;
return true;
default: return false;
}
}
};