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>
This commit is contained in:
Jakub
2026-09-25 20:09:02 +02:00
co-authored by Claude Opus 5.5
parent e2399b5e0d
commit 1c8206a510
7 changed files with 535 additions and 226 deletions
+10
View File
@@ -66,6 +66,16 @@ Confirmed: green dot inside the ring, waypoints much better, download works, set
- [ ] Room server login from the phone app / L1 still works while this exists (only admin logins go to the new session)
- [ ] L1 (ui-new): Tools > Admin works as before (login, saved password, typed values, confirm reboot); it now also skips the login for the node you just used
## New: Bot (2026-09-25)
- [ ] Home page 2: Clock, Bot
- [ ] Bot: tabs Channel / Room / Direct / Other; header shows "N sent" once it has replied
- [ ] Switches (Enable, Commands, Actions) change and survive a reboot; Direct > DM allow All / Fav
- [ ] Channel / Room: list popup (current one highlighted), picking closes it and shows the name
- [ ] Trigger: text popup ("*" = any message, shown as "(any msg)"); Reply: {name} {hops} {loc} {time} buttons insert at the cursor
- [ ] Other > Quiet from / to: − / + hour, Done; both the same = "Off"
- [ ] With Channel enabled + trigger "!hi" + reply "Hi {name}, {hops}": another node writing "!hi" on that channel gets the reply; `!ping` answered when Commands is on
- [ ] L1 (ui-new): Tools > Bot looks and works as before (now from the shared BotConfig table)
## Basics after the_mesh moved to PSRAM (`MESH_IN_PSRAM`)
- [ ] Boots normally, contacts and channels are all there
- [ ] Messages arrive and send (channel + DM), delivery ticks work
+1 -1
View File
@@ -110,7 +110,7 @@ call the same ones, so e.g. "marking a channel read" has one implementation.
### 5. Declarative settings schema
`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h`. Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it.
`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h`. Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh.
Replaces the per-item code in `SettingsScreen`:
@@ -0,0 +1,137 @@
#pragma once
// The auto-reply bot's settings as a table the frontends render (ui-new's
// Tools > Bot, ui-lvgl's Bot screen). The bot itself runs in MyMesh; these
// are only its NodePrefs fields, grouped by where it answers: a channel, a
// room server, direct messages, plus quiet hours.
//
// Row kinds: switches (a uint8_t flag), text (trigger / reply, with a
// capacity), a target (channel / room: a picker in the frontend), an hour
// (quiet from / to; equal hours = quiet hours off).
namespace botcfg {
enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_COUNT };
static const char* const TAB_LABELS[TAB_COUNT] = { "Channel", "Room", "Direct", "Other" };
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 };
// hint: a one-line explanation for frontends with room for it.
struct Row { Kind kind; const char* label; const char* hint; };
static const Row CHANNEL_ROWS[] = {
{ ENABLE_CH, "Enable", "Answer on one channel" },
{ CHANNEL, "Channel", nullptr },
{ COMMANDS_CH, "Commands", "!ping, !help and other ! commands" },
{ ACTIONS_CH, "Actions", "!gps, !gpio... (change this device)" },
{ TRIGGER_CH, "Trigger", "Text that makes it reply; * = any" },
{ REPLY_CH, "Reply", "{name} {hops} {loc} {time} ..." },
};
static const Row ROOM_ROWS[] = {
{ ENABLE_ROOM, "Enable", "Answer in one room server" },
{ ROOM, "Room", nullptr },
{ COMMANDS_ROOM, "Commands", "!ping, !help and other ! commands" },
{ ACTIONS_ROOM, "Actions", "!gps, !gpio... (change this device)" },
{ TRIGGER_ROOM, "Trigger", "Text that makes it reply; * = any" },
{ REPLY_ROOM, "Reply", "{name} {hops} {loc} {time} ..." },
};
static const Row DIRECT_ROWS[] = {
{ ENABLE_DM, "Enable", "Answer direct messages" },
{ DM_SCOPE, "DM allow", "Everyone, or favourites only" },
{ COMMANDS_DM, "Commands", "!ping, !help and other ! commands" },
{ ACTIONS_DM, "Actions", "!gps, !gpio... (change this device)" },
{ TRIGGER_DM, "Trigger", "Text that makes it reply; * = any" },
{ REPLY_DM, "Reply", "{name} {hops} {loc} {time} ..." },
};
static const Row OTHER_ROWS[] = {
{ QUIET_FROM, "Quiet from", "No replies between these hours" },
{ QUIET_TO, "Quiet to", "Same hour on both = always on" },
};
static int rowCount(int tab) {
switch (tab) {
case TAB_CHANNEL: return sizeof(CHANNEL_ROWS) / sizeof(Row);
case TAB_ROOM: return sizeof(ROOM_ROWS) / sizeof(Row);
case TAB_DIRECT: return sizeof(DIRECT_ROWS) / sizeof(Row);
default: return sizeof(OTHER_ROWS) / sizeof(Row);
}
}
static const Row& row(int tab, int idx) {
switch (tab) {
case TAB_CHANNEL: return CHANNEL_ROWS[idx];
case TAB_ROOM: return ROOM_ROWS[idx];
case TAB_DIRECT: return DIRECT_ROWS[idx];
default: return OTHER_ROWS[idx];
}
}
// On / off rows: the flag; nullptr for other kinds.
static uint8_t* flag(NodePrefs* p, Kind k) {
switch (k) {
case ENABLE_DM: return &p->bot_enabled;
case ENABLE_CH: return &p->bot_channel_enabled;
case ENABLE_ROOM: return &p->bot_room_enabled;
case DM_SCOPE: return &p->bot_dm_scope; // 1 = favourites only
case COMMANDS_DM: return &p->bot_commands_enabled;
case COMMANDS_CH: return &p->bot_commands_ch;
case COMMANDS_ROOM: return &p->bot_commands_room;
case ACTIONS_DM: return &p->bot_actions_dm;
case ACTIONS_CH: return &p->bot_actions_ch;
case ACTIONS_ROOM: return &p->bot_actions_room;
default: return nullptr;
}
}
// Text rows: the buffer and its size (incl. NUL); nullptr for other kinds.
static char* text(NodePrefs* p, Kind k, int& cap) {
switch (k) {
case TRIGGER_DM: cap = sizeof(p->bot_trigger); return p->bot_trigger;
case REPLY_DM: cap = sizeof(p->bot_reply_dm); return p->bot_reply_dm;
case TRIGGER_CH: cap = sizeof(p->bot_trigger_ch); return p->bot_trigger_ch;
case REPLY_CH: cap = sizeof(p->bot_reply_ch); return p->bot_reply_ch;
case TRIGGER_ROOM: cap = sizeof(p->bot_trigger_room); return p->bot_trigger_room;
case REPLY_ROOM: cap = sizeof(p->bot_reply_room); return p->bot_reply_room;
default: cap = 0; return nullptr;
}
}
static bool isTrigger(Kind k) { return k == TRIGGER_DM || k == TRIGGER_CH || k == TRIGGER_ROOM; }
static bool isHour(Kind k) { return k == QUIET_FROM || k == QUIET_TO; }
static uint8_t& hour(NodePrefs* p, Kind k) { return k == QUIET_FROM ? p->bot_quiet_start : p->bot_quiet_end; }
static bool quietOff(const NodePrefs* p) { return p->bot_quiet_start == p->bot_quiet_end; }
// Placeholders a reply can use: the bot fills {name} / {hops} from the
// message it answers; {loc} / {time} and the sensor ones come from here.
// Most useful first (a frontend short of room shows a prefix).
static const char* const REPLY_PLACEHOLDERS[] = { "{name}", "{hops}", "{loc}", "{time}", "{batt}" };
static const int REPLY_PLACEHOLDER_COUNT = sizeof(REPLY_PLACEHOLDERS) / sizeof(REPLY_PLACEHOLDERS[0]);
// Shown value of a text row ("(none)", "(any msg)" for the "*" trigger).
static const char* shownText(const char* t, bool trigger) {
if (!t[0]) return "(none)";
if (trigger && t[0] == '*' && !t[1]) return "(any msg)"; // wildcard / away mode
return t;
}
// Target names: false when none is set / it no longer exists.
static bool channelName(const NodePrefs* p, char* out, size_t n) {
ChannelDetails ch;
if (!the_mesh.getChannel(p->bot_channel_idx, ch) || !ch.name[0]) return false;
snprintf(out, n, "%s", ch.name);
return true;
}
static bool roomName(const NodePrefs* p, char* out, size_t n) {
ContactInfo* c = the_mesh.lookupContactByPubKey(p->bot_room_prefix, NodePrefs::FAVOURITE_PREFIX_LEN);
if (!c || !c->name[0]) return false;
snprintf(out, n, "%s", c->name);
return true;
}
static void setRoom(NodePrefs* p, const uint8_t* pub_key) {
memcpy(p->bot_room_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
}
} // namespace botcfg
@@ -0,0 +1,305 @@
#pragma once
// Auto-reply bot settings (Home > Bot) -- ui-new's Tools > Bot. The rows and
// their NodePrefs fields are ui-core/BotConfig.h; the bot runs in MyMesh.
// Tabs Channel / Room / Direct / Other; switches inline, trigger / reply in a
// popup with the keyboard (reply: placeholder buttons above it), channel /
// room from a list popup, quiet hours with - / +.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after AdminScreen.h.
namespace botview {
static uint8_t s_tab = botcfg::TAB_CHANNEL;
static int s_row = -1; // row a popup is editing
static lv_obj_t* s_hour_lbl = nullptr;
static uint8_t s_rooms[32][NodePrefs::FAVOURITE_PREFIX_LEN];
enum : uint8_t { B_MINUS, B_PLUS, B_DONE };
} // namespace botview
static void onBotTab(lv_event_t* e) {
s_ui->botTab((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onBotRow(lv_event_t* e) { s_ui->botRow((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onBotSwitch(lv_event_t* e) {
s_ui->botToggle((int)(uintptr_t)lv_event_get_user_data(e),
lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
}
static void onBotScope(lv_event_t* e) {
s_ui->botToggle((int)(uintptr_t)lv_event_get_user_data(e),
lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)) == 1);
}
static void onBotPick(lv_event_t* e) { s_ui->botPick((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onBotHour(lv_event_t* e) { s_ui->botHour((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onBotTextKb(lv_event_t* e) { s_ui->botTextDone(lv_event_get_code(e) == LV_EVENT_READY); }
static void onBotPlaceholder(lv_event_t* e) {
lv_obj_t* ta = (lv_obj_t*)lv_event_get_user_data(e);
lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
lv_textarea_add_text(ta, lv_label_get_text(lv_obj_get_child(btn, 0)));
}
static void onOpenBot(lv_event_t* e) { (void)e; s_ui->showBot(); }
static lv_obj_t* botHourButton(lv_obj_t* parent, const char* text, uint8_t act, bool accent) {
lv_obj_t* b = lv_button_create(parent);
lv_obj_set_height(b, 40);
lv_obj_set_flex_grow(b, 1);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(accent ? theme::ACCENT_DIM : theme::SURFACE), 0);
lv_obj_add_event_cb(b, onBotHour, LV_EVENT_CLICKED, (void*)(uintptr_t)act);
lv_obj_center(label(b, text, act == botview::B_DONE ? THEME_FONT_BODY : THEME_FONT_LARGE, theme::TEXT));
return b;
}
void UITask::showBot() {
_screen = SCR_BOT;
buildBot();
}
static lv_obj_t* segmented(lv_obj_t* parent, const char** map, int sel, int w, int h) {
lv_obj_t* m = lv_buttonmatrix_create(parent);
lv_buttonmatrix_set_map(m, map);
lv_buttonmatrix_set_button_ctrl_all(m, LV_BUTTONMATRIX_CTRL_CHECKABLE);
lv_buttonmatrix_set_one_checked(m, true);
lv_buttonmatrix_set_button_ctrl(m, sel, LV_BUTTONMATRIX_CTRL_CHECKED);
lv_obj_set_size(m, w, h);
lv_obj_set_style_pad_all(m, 0, 0);
lv_obj_set_style_pad_column(m, 4, 0);
lv_obj_set_style_bg_opa(m, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(m, 0, 0);
lv_obj_set_style_bg_color(m, lv_color_hex(theme::SURFACE_2), LV_PART_ITEMS);
lv_obj_set_style_bg_color(m, lv_color_hex(theme::ACCENT_DIM), LV_PART_ITEMS | LV_STATE_CHECKED);
lv_obj_set_style_text_color(m, lv_color_hex(theme::TEXT), LV_PART_ITEMS);
lv_obj_set_style_text_font(m, THEME_FONT_SMALL, LV_PART_ITEMS);
lv_obj_set_style_shadow_width(m, 0, LV_PART_ITEMS);
lv_obj_set_style_radius(m, 8, LV_PART_ITEMS);
return m;
}
void UITask::buildBot() {
using namespace botview;
lv_obj_t* body = newScreen("Bot", true);
lv_obj_set_style_pad_row(body, 4, 0);
NodePrefs* p = _prefs;
if (!p) return;
uint16_t sent = the_mesh.botReplyCount();
if (sent > 0 && _header) { // replies since boot
lv_obj_t* c = label(_header, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_text_fmt(c, "%u sent", (unsigned)sent);
lv_obj_align(c, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
}
static const char* TABS[botcfg::TAB_COUNT + 1];
for (int i = 0; i < botcfg::TAB_COUNT; i++) TABS[i] = botcfg::TAB_LABELS[i];
TABS[botcfg::TAB_COUNT] = "";
lv_obj_t* tabs = segmented(body, TABS, s_tab, lv_pct(100), 34);
lv_obj_set_style_bg_color(tabs, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
lv_obj_add_event_cb(tabs, onBotTab, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_t* list = lv_obj_create(body);
styleSurface(list, theme::BG);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_ACTIVE);
for (int i = 0; i < botcfg::rowCount(s_tab); i++) {
const botcfg::Row& r = botcfg::row(s_tab, i);
uint8_t* fl = botcfg::flag(p, r.kind);
lv_obj_t* row = radioview::settingRow(list, r.label, fl || botcfg::isHour(r.kind) ? r.hint : nullptr);
if (fl && r.kind == botcfg::DM_SCOPE) {
static const char* SCOPE[] = { "All", "Fav", "" };
lv_obj_t* seg = segmented(row, SCOPE, *fl ? 1 : 0, 110, 32);
lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0);
lv_obj_add_event_cb(seg, onBotScope, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i);
continue;
}
if (fl) {
lv_obj_t* sw = lv_switch_create(row);
lv_obj_set_size(sw, 46, 24);
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
if (*fl) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onBotSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i);
continue;
}
// Value on the right; tap the row to change it.
char val[48];
int cap;
if (const char* t = botcfg::text(p, r.kind, cap)) snprintf(val, sizeof(val), "%s", botcfg::shownText(t, botcfg::isTrigger(r.kind)));
else if (r.kind == botcfg::CHANNEL) { if (!botcfg::channelName(p, val, sizeof(val))) snprintf(val, sizeof(val), "(pick one)"); }
else if (r.kind == botcfg::ROOM) { if (!botcfg::roomName(p, val, sizeof(val))) snprintf(val, sizeof(val), "(pick one)"); }
else if (botcfg::quietOff(p)) snprintf(val, sizeof(val), "Off");
else snprintf(val, sizeof(val), "%02d:00", botcfg::hour(p, r.kind));
lv_obj_t* v = label(row, val, THEME_FONT_BODY, theme::ACCENT);
lv_label_set_long_mode(v, LV_LABEL_LONG_DOT);
lv_obj_set_width(v, botcfg::isHour(r.kind) ? 80 : 190); // fixed: LONG_DOT needs a width
lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0);
lv_obj_align(v, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
lv_obj_add_event_cb(row, onBotRow, LV_EVENT_CLICKED, (void*)(uintptr_t)i);
}
}
void UITask::botTab(int tab) {
if (tab < 0 || tab >= botcfg::TAB_COUNT) return;
botview::s_tab = (uint8_t)tab;
buildBot();
}
void UITask::botToggle(int row, bool on) {
if (!_prefs || row < 0 || row >= botcfg::rowCount(botview::s_tab)) return;
uint8_t* fl = botcfg::flag(_prefs, botcfg::row(botview::s_tab, row).kind);
if (!fl) return;
*fl = on ? 1 : 0;
the_mesh.savePrefs();
}
// Tap on a value row: its editor.
void UITask::botRow(int row) {
using namespace botview;
NodePrefs* p = _prefs;
if (!p || row < 0 || row >= botcfg::rowCount(s_tab)) return;
s_row = row;
const botcfg::Row& r = botcfg::row(s_tab, row);
int cap;
if (char* t = botcfg::text(p, r.kind, cap)) {
bool reply = !botcfg::isTrigger(r.kind);
lv_obj_t* panel = navPopupPanel(r.label, false);
lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard
_nav_ta = lv_textarea_create(panel);
lv_textarea_set_one_line(_nav_ta, true);
lv_textarea_set_max_length(_nav_ta, cap - 1);
lv_textarea_set_text(_nav_ta, t);
lv_textarea_set_placeholder_text(_nav_ta, botcfg::isTrigger(r.kind) ? "e.g. !hi, or * for any message" : "Reply text");
lv_obj_set_size(_nav_ta, LV_PCT(100), 34);
lv_obj_set_style_border_width(_nav_ta, 1, 0);
lv_obj_set_style_pad_ver(_nav_ta, 6, 0);
lv_obj_set_style_pad_hor(_nav_ta, 10, 0);
lv_obj_set_scrollbar_mode(_nav_ta, LV_SCROLLBAR_MODE_OFF);
lv_obj_set_style_border_color(_nav_ta, lv_color_hex(theme::ACCENT), LV_PART_CURSOR | LV_STATE_FOCUSED);
lv_obj_set_style_border_width(_nav_ta, 2, LV_PART_CURSOR | LV_STATE_FOCUSED);
lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED);
if (reply) {
// Placeholders the bot fills in, as buttons in place of the title (no
// room for another row above the keyboard; the field says "Reply").
lv_obj_t* hdr = lv_obj_get_child(panel, 0);
lv_obj_add_flag(lv_obj_get_child(hdr, 0), LV_OBJ_FLAG_HIDDEN);
lv_obj_t* chips = lv_obj_create(hdr);
lv_obj_remove_style_all(chips);
lv_obj_set_size(chips, 240, 28);
lv_obj_align(chips, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_gap(chips, 4, 0);
for (int i = 0; i < botcfg::REPLY_PLACEHOLDER_COUNT - 1; i++) { // {batt} and sensors: type them
lv_obj_t* b = lv_button_create(chips);
lv_obj_set_height(b, 26);
lv_obj_set_style_pad_hor(b, 7, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_set_style_radius(b, 13, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_add_event_cb(b, onBotPlaceholder, LV_EVENT_CLICKED, _nav_ta);
lv_obj_center(label(b, botcfg::REPLY_PLACEHOLDERS[i], THEME_FONT_SMALL, theme::TEXT));
}
}
_nav_kb = kb::create(_nav_overlay, _prefs);
lv_obj_set_size(_nav_kb, LV_PCT(100), 124);
lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_keyboard_set_textarea(_nav_kb, _nav_ta);
lv_obj_add_event_cb(_nav_kb, onBotTextKb, LV_EVENT_READY, NULL);
lv_obj_add_event_cb(_nav_kb, onBotTextKb, LV_EVENT_CANCEL, NULL);
return;
}
if (r.kind == botcfg::CHANNEL || r.kind == botcfg::ROOM) {
lv_obj_t* panel = navPopupPanel(r.kind == botcfg::CHANNEL ? "Answer on channel" : "Answer in room", true);
lv_obj_t* list = lv_obj_create(panel);
styleSurface(list, theme::BG);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(list, theme::GAP, 0);
int n = 0;
if (r.kind == botcfg::CHANNEL) {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || !ch.name[0]) continue;
lv_obj_t* rw = listRow(list, ch.name, NULL, onBotPick, (void*)(uintptr_t)i);
if (i == p->bot_channel_idx) lv_obj_set_style_bg_color(rw, lv_color_hex(theme::ACCENT_DIM), 0);
n++;
}
} else {
int total = the_mesh.getNumContacts();
for (int i = 0; i < total && n < (int)(sizeof(s_rooms) / sizeof(s_rooms[0])); i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) || c.type != ADV_TYPE_ROOM) continue;
memcpy(s_rooms[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
lv_obj_t* rw = listRow(list, c.name, NULL, onBotPick, (void*)(uintptr_t)n);
if (memcmp(c.id.pub_key, p->bot_room_prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0)
lv_obj_set_style_bg_color(rw, lv_color_hex(theme::ACCENT_DIM), 0);
n++;
}
}
if (n == 0) label(list, r.kind == botcfg::CHANNEL ? "No channels." : "No room servers in your contacts.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
return;
}
if (botcfg::isHour(r.kind)) {
lv_obj_t* panel = navPopupPanel(r.label, false);
label(panel, "Same hour for both = no quiet hours.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* rw = adminRowBox(panel);
botHourButton(rw, LV_SYMBOL_MINUS, B_MINUS, false);
s_hour_lbl = label(rw, "", THEME_FONT_LARGE, theme::TEXT);
lv_obj_set_width(s_hour_lbl, 80);
lv_obj_set_style_text_align(s_hour_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text_fmt(s_hour_lbl, "%02d:00", botcfg::hour(p, r.kind));
botHourButton(rw, LV_SYMBOL_PLUS, B_PLUS, false);
rw = adminRowBox(panel);
botHourButton(rw, LV_SYMBOL_OK " Done", B_DONE, true);
}
}
void UITask::botTextDone(bool ok) {
using namespace botview;
if (ok && _nav_ta && _prefs && s_row >= 0 && s_row < botcfg::rowCount(s_tab)) {
int cap;
char* t = botcfg::text(_prefs, botcfg::row(s_tab, s_row).kind, cap);
if (t) {
snprintf(t, cap, "%s", lv_textarea_get_text(_nav_ta));
the_mesh.savePrefs();
}
}
navClosePopup();
if (ok) buildBot();
}
void UITask::botPick(int idx) {
using namespace botview;
if (!_prefs || s_row < 0) return;
botcfg::Kind k = botcfg::row(s_tab, s_row).kind;
if (k == botcfg::CHANNEL) _prefs->bot_channel_idx = (uint8_t)idx;
else if (k == botcfg::ROOM && idx >= 0 && idx < (int)(sizeof(s_rooms) / sizeof(s_rooms[0]))) botcfg::setRoom(_prefs, s_rooms[idx]);
the_mesh.savePrefs();
navClosePopup();
buildBot();
}
void UITask::botHour(uint8_t act) {
using namespace botview;
if (!_prefs || s_row < 0) return;
botcfg::Kind k = botcfg::row(s_tab, s_row).kind;
uint8_t& h = botcfg::hour(_prefs, k);
if (act == B_DONE) {
the_mesh.savePrefs();
navClosePopup();
buildBot();
return;
}
h = (uint8_t)((h + (act == B_PLUS ? 1 : 23)) % 24);
if (s_hour_lbl) lv_label_set_text_fmt(s_hour_lbl, "%02d:00", h);
}
@@ -14,6 +14,7 @@
#include "../ui-core/TrackBack.h"
#include "../ui-core/RadioControl.h"
#include "../ui-core/ChannelControl.h"
#include "../ui-core/BotConfig.h"
#include "Theme.h"
#include "LvglPort.h"
#include "../ui-core/KeyboardData.h"
@@ -458,6 +459,7 @@ void UITask::back() {
case SCR_THREAD: if (_nav_overlay) navClosePopup(); else showChats(); break;
case SCR_CONTACTS: showChats(); break;
case SCR_CHANNEL_EDIT: showChats(); break;
case SCR_BOT: if (_nav_overlay) navClosePopup(); else showHome(); break;
case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break;
case SCR_SETTINGS: showHome(); break;
case SCR_SETTINGS_NAV: showSettings(); break;
@@ -494,6 +496,7 @@ static void onOpenNearby(lv_event_t* e) { (void)e; s_ui->showNearby(); }
static void onOpenNodesMap(lv_event_t* e) { (void)e; s_ui->openMap(false); }
static void onOpenNav(lv_event_t* e) { (void)e; s_ui->openMap(true); }
static void onOpenClock(lv_event_t* e); // ClockScreen.h
static void onOpenBot(lv_event_t* e); // BotScreen.h
// Home apps, in order; PER_PAGE to a page, further pages are swiped to (with
// dots underneath), so new apps don't squeeze the row.
@@ -506,6 +509,7 @@ static const App APPS[] = {
{ LV_SYMBOL_SETTINGS, "Settings", onOpenSettings, false },
// Page 2: tools
{ UI_SYMBOL_CLOCK, "Clock", onOpenClock, false },
{ LV_SYMBOL_CHARGE, "Bot", onOpenBot, false },
};
static const int COUNT = sizeof(APPS) / sizeof(APPS[0]);
static const int PER_PAGE = 4;
@@ -1263,6 +1267,7 @@ void UITask::nodeAction(uint8_t action) {
#include "WifiScreen.h"
#include "ChannelScreen.h"
#include "AdminScreen.h"
#include "BotScreen.h"
// ── Conversation ──────────────────────────────────────────────────────────────
+10 -1
View File
@@ -128,6 +128,14 @@ public:
void adminLogin(bool ok);
void adminTextDone(bool ok);
void adminCancelWait();
// Bot settings (BotScreen.h)
void showBot();
void botTab(int tab);
void botRow(int row);
void botToggle(int row, bool on);
void botPick(int idx);
void botHour(uint8_t act);
void botTextDone(bool ok);
void setGps(bool on);
bool ensureGps(); // true with a fix; else turns GPS on / says it's waiting
@@ -136,7 +144,7 @@ public:
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; }
private:
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN };
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT };
void buildStatusBar();
void refreshStatusBar();
@@ -192,6 +200,7 @@ private:
void radioCloseFreq();
void buildChannelEdit();
void buildAdmin();
void buildBot();
void refreshAdminStatus();
void adminLeave();
void adminPoll();
+67 -224
View File
@@ -3,6 +3,7 @@
// Included by UITask.cpp after KeyboardWidget.h is defined.
#include "TabBar.h"
#include "../ui-core/BotConfig.h"
class BotScreen : public UIScreen {
UITask* _task;
@@ -11,59 +12,11 @@ class BotScreen : public UIScreen {
// 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];
}
}
// 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
@@ -109,12 +62,12 @@ class BotScreen : public UIScreen {
// 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);
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(TAB_CHANNEL), _kb(kb) {}
: _task(task), _prefs(prefs), _tab(botcfg::TAB_CHANNEL), _kb(kb) {}
void onShow() override {
// _tab persists across visits (like NearbyScreen's _filter) — only reset
@@ -154,7 +107,7 @@ public:
display.setColor(DisplayDriver::LIGHT);
}
int n = ROWS_PER_TAB[_tab];
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);
@@ -163,106 +116,39 @@ public:
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]) {
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, ch.name, sel);
if (sel && mqr > 0) mq_delay = mqr;
} 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]) {
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, c->name, sel);
if (sel && mqr > 0) mq_delay = mqr;
} 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;
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, shown, sel);
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;
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;
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, rp[0] ? rp : "(none)", sel);
if (sel && mqr > 0) mq_delay = mqr;
break;
} 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);
}
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);
});
@@ -279,8 +165,8 @@ public:
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
Kind k = tabRow(_tab, _kb_row).kind;
char* dst = fieldBuf(k);
int cap = fieldCap(k);
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;
@@ -291,8 +177,7 @@ public:
}
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;
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; }
@@ -304,10 +189,10 @@ public:
_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; }
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 = ROWS_PER_TAB[_tab];
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; }
@@ -315,83 +200,41 @@ public:
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 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:
case botcfg::CHANNEL:
// Full browsable channel picker (same experience as Live Share's "To"
// row); which channel is *active* is now the separate Enable row.
// row); which channel is *active* is the separate Enable row.
if (_num_channels == 0) return false;
_task->savePrefsIfDirty(_dirty);
_task->pickBotChannelTarget();
return true;
case ROOM:
case botcfg::ROOM:
if (_num_rooms == 0) return false;
_task->savePrefsIfDirty(_dirty);
_task->pickBotRoomTarget();
return true;
case QUIET_FROM:
case QUIET_TO:
case botcfg::QUIET_FROM:
case botcfg::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 };