From 1c8206a5107f238a9e61ee24e0025b7f9505aef3 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:09:02 +0200 Subject: [PATCH] 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 --- docs/development/l2-hardware-checklist.md | 10 + docs/development/ui-core.md | 2 +- examples/companion_radio/ui-core/BotConfig.h | 137 +++++++++ examples/companion_radio/ui-lvgl/BotScreen.h | 305 +++++++++++++++++++ examples/companion_radio/ui-lvgl/UITask.cpp | 5 + examples/companion_radio/ui-lvgl/UITask.h | 11 +- examples/companion_radio/ui-new/BotScreen.h | 291 ++++-------------- 7 files changed, 535 insertions(+), 226 deletions(-) create mode 100644 examples/companion_radio/ui-core/BotConfig.h create mode 100644 examples/companion_radio/ui-lvgl/BotScreen.h diff --git a/docs/development/l2-hardware-checklist.md b/docs/development/l2-hardware-checklist.md index 8ab5157c..11f01ae9 100644 --- a/docs/development/l2-hardware-checklist.md +++ b/docs/development/l2-hardware-checklist.md @@ -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 diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index 8a38c7f0..2dca4432 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -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`: diff --git a/examples/companion_radio/ui-core/BotConfig.h b/examples/companion_radio/ui-core/BotConfig.h new file mode 100644 index 00000000..96905710 --- /dev/null +++ b/examples/companion_radio/ui-core/BotConfig.h @@ -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 diff --git a/examples/companion_radio/ui-lvgl/BotScreen.h b/examples/companion_radio/ui-lvgl/BotScreen.h new file mode 100644 index 00000000..437f26f8 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/BotScreen.h @@ -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); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 554ee049..19ac9475 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -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 ────────────────────────────────────────────────────────────── diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index fe5342e3..19966f99 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -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(); diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index f8e6b8d9..3d1135c8 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -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 };