mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-27 07:18:11 +00:00
Merge wio-tracker-l1-improvements into font-experiments
Brings in reply feature (@[nick] format), screen lock auto-lock, README updates, and scroll-arrow overlap fix (FS_CHARS_MAX kept at 80 for pixel-accurate wrapping). FullscreenMsgView conflict resolved by preserving font-experiments' DisplayDriver-based wrapLines and dynamic lineH while adding wio-tracker's @[nick] header parsing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
14
README.md
14
README.md
@@ -10,9 +10,9 @@ Join the discussion on offical MeshCore discord: https://discord.gg/sdhYArU2jr
|
||||
|
||||
View and send messages using the on-screen keyboard or predefined quick replies. The keyboard supports placeholders that insert live sensor data — `{time}` and `{loc}` are always available; additional placeholders (`{temp}`, `{hum}`, `{pres}`, `{batt}`, `{alt}`, `{lux}`, `{co2}`) appear automatically for sensors that are connected and returning data.
|
||||
|
||||
Press Enter on a message to open it in fullscreen. Navigate between messages with left (newer) and right (older).
|
||||
Press Enter on a message to open it in fullscreen. Navigate between messages with left (newer) and right (older). If the message is a reply addressed to someone (`@[nick]`), a **To: nick** bar is shown below the sender name and the body is displayed without the address prefix.
|
||||
|
||||
Hold Enter on a message or channel to open a context menu: change per-channel notification settings (mute, follow global, or force-on) and per-channel melody override (follow global, Melody 1, or Melody 2), or mark messages as read.
|
||||
Hold Enter on a message to open a context menu. From the list or fullscreen view, select **Reply** to pre-fill the keyboard or a quick message with `@[nick]` so the recipient is clearly addressed. On channel or contact list entries, the context menu also lets you change per-channel notification settings (mute, follow global, or force-on), per-channel melody override (follow global, Melody 1, or Melody 2), or mark messages as read.
|
||||
|
||||
### Settings Screen
|
||||
|
||||
@@ -34,6 +34,7 @@ All settings are saved to flash and restored on next boot.
|
||||
- **System**
|
||||
- Timezone (UTC offset in hours)
|
||||
- Low battery shutdown threshold
|
||||
- Auto-lock — automatically locks the device when the display turns off
|
||||
- **GPS**
|
||||
- Position broadcast interval
|
||||
- **Contacts**
|
||||
@@ -48,6 +49,15 @@ A dedicated clock page on the home screen shows the current time and date, synch
|
||||
|
||||
Up to three configurable data fields are displayed below the clock. Available fields: battery voltage, temperature, humidity, pressure, GPS coordinates, altitude, luminosity, CO₂, contact count, and total unread message count.
|
||||
|
||||
### Screen Lock
|
||||
|
||||
Hold **Back** and press **Enter** three times to lock or unlock the device. While locked:
|
||||
|
||||
- The display turns off and ignores incoming keypresses
|
||||
- A brief button press shows the lock screen: current time, date, and up to two sensor values (reuses the Dashboard Config fields)
|
||||
- A hint popup guides through the unlock sequence
|
||||
- **Auto-lock** (configurable in Settings → System) locks automatically when the display turns off
|
||||
|
||||
### Nearby Nodes
|
||||
|
||||
Browse nodes that have recently advertised on the mesh. Filter by category (Favourites, All, Companion, Repeater, Room, Sensor). Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time.
|
||||
|
||||
@@ -12,7 +12,7 @@ struct FullscreenMsgView {
|
||||
|
||||
FullscreenMsgView() : scroll(0), active(false) {}
|
||||
|
||||
enum Result { NONE, PREV, NEXT, CLOSE };
|
||||
enum Result { NONE, PREV, NEXT, CLOSE, REPLY };
|
||||
|
||||
void begin() { scroll = 0; active = true; }
|
||||
|
||||
@@ -74,37 +74,60 @@ struct FullscreenMsgView {
|
||||
display.setTextSize(1);
|
||||
const int lineH = display.getLineHeight();
|
||||
const int max_px = display.width() - 6;
|
||||
const int visible = (display.height() - FS_START_Y - lineH) / lineH;
|
||||
|
||||
// detect @recipient at start of message
|
||||
char to_nick[32] = "";
|
||||
const char* body = text;
|
||||
if (text[0] == '@' && text[1] == '[') {
|
||||
const char* close = strchr(text + 2, ']');
|
||||
if (close && close[1] == ' ' && close[2]) {
|
||||
int len = (int)(close - text) - 2;
|
||||
if (len >= (int)sizeof(to_nick)) len = sizeof(to_nick) - 1;
|
||||
memcpy(to_nick, text + 2, len);
|
||||
to_nick[len] = '\0';
|
||||
body = close + 2;
|
||||
}
|
||||
}
|
||||
|
||||
const int header_h = to_nick[0] ? 20 : 10;
|
||||
const int startY = header_h + 2;
|
||||
const int visible = (display.height() - startY - lineH) / lineH;
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, 0, display.width(), 10);
|
||||
display.fillRect(0, 0, display.width(), header_h);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.drawTextEllipsized(2, 1, display.width() - 4, sender);
|
||||
if (to_nick[0]) {
|
||||
char trans_nick[32], to_label[36];
|
||||
display.translateUTF8ToBlocks(trans_nick, to_nick, sizeof(trans_nick));
|
||||
snprintf(to_label, sizeof(to_label), "To: %s", trans_nick);
|
||||
display.drawTextEllipsized(2, 11, display.width() - 4, to_label);
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
char trans_text[512];
|
||||
display.translateUTF8ToBlocks(trans_text, text, sizeof(trans_text));
|
||||
display.translateUTF8ToBlocks(trans_text, body, sizeof(trans_text));
|
||||
char lines[12][FS_CHARS_MAX];
|
||||
int lcount = wrapLines(display, trans_text, max_px, lines, 12);
|
||||
int max_scroll = lcount > visible ? lcount - visible : 0;
|
||||
if (scroll > max_scroll) scroll = max_scroll;
|
||||
|
||||
for (int i = 0; i < visible && (scroll + i) < lcount; i++) {
|
||||
display.setCursor(0, FS_START_Y + i * lineH);
|
||||
display.setCursor(0, startY + i * lineH);
|
||||
display.print(lines[scroll + i]);
|
||||
}
|
||||
if (scroll > 0) {
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(display.width() - 6, FS_START_Y, 6, lineH);
|
||||
display.fillRect(display.width() - 6, startY, 6, lineH);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(display.width() - 6, FS_START_Y);
|
||||
display.setCursor(display.width() - 6, startY);
|
||||
display.print("^");
|
||||
}
|
||||
if (scroll < max_scroll) {
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(display.width() - 6, FS_START_Y + (visible - 1) * lineH, 6, lineH);
|
||||
display.fillRect(display.width() - 6, startY + (visible - 1) * lineH, 6, lineH);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(display.width() - 6, FS_START_Y + (visible - 1) * lineH);
|
||||
display.setCursor(display.width() - 6, startY + (visible - 1) * lineH);
|
||||
display.print("v");
|
||||
}
|
||||
const int nav_y = display.height() - lineH;
|
||||
@@ -120,10 +143,11 @@ struct FullscreenMsgView {
|
||||
}
|
||||
|
||||
Result handleInput(char c) {
|
||||
if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; }
|
||||
if (c == KEY_DOWN) { scroll++; return NONE; }
|
||||
if (c == KEY_LEFT) return NEXT;
|
||||
if (c == KEY_RIGHT) return PREV;
|
||||
if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; }
|
||||
if (c == KEY_DOWN) { scroll++; return NONE; }
|
||||
if (c == KEY_LEFT) return NEXT;
|
||||
if (c == KEY_RIGHT) return PREV;
|
||||
if (c == KEY_CONTEXT_MENU) return REPLY;
|
||||
if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE;
|
||||
return NONE;
|
||||
}
|
||||
|
||||
@@ -41,11 +41,13 @@ class QuickMsgScreen : public UIScreen {
|
||||
// KEYBOARD
|
||||
KeyboardWidget _kb;
|
||||
|
||||
// Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK)
|
||||
// Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK / histories)
|
||||
PopupMenu _ctx_menu;
|
||||
bool _ctx_dirty;
|
||||
char _ctx_notif_item[22];
|
||||
char _ctx_melody_item[20];
|
||||
char _reply_prefix[36]; // "@[nick] " built when reply is triggered
|
||||
bool _reply_mode; // true while composing a reply (prefix is prepended)
|
||||
|
||||
struct ChHistEntry { uint8_t ch_idx; char text[140]; };
|
||||
ChHistEntry _hist[CH_HIST_MAX];
|
||||
@@ -86,6 +88,25 @@ class QuickMsgScreen : public UIScreen {
|
||||
&sensors, batt);
|
||||
}
|
||||
|
||||
// Build "@[nick] " prefix from a channel message text ("nick: body") into _reply_prefix.
|
||||
// Returns false if sender is "Me" (own message — no reply prefix needed).
|
||||
bool buildChannelReplyPrefix(const char* text) {
|
||||
const char* sep = strstr(text, ": ");
|
||||
if (!sep) return false;
|
||||
int slen = (int)(sep - text);
|
||||
if (slen == 2 && strncmp(text, "Me", 2) == 0) return false;
|
||||
if (slen > 31) slen = 31;
|
||||
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.*s] ", slen, text);
|
||||
return true;
|
||||
}
|
||||
|
||||
void startReply(bool to_channel) {
|
||||
_sending_to_channel = to_channel;
|
||||
_reply_mode = true;
|
||||
setupMsgPick();
|
||||
_phase = MSG_PICK;
|
||||
}
|
||||
|
||||
void setupMsgPick() {
|
||||
_msg_sel = _msg_scroll = 0;
|
||||
_active_msg_count = 0;
|
||||
@@ -167,6 +188,7 @@ class QuickMsgScreen : public UIScreen {
|
||||
}
|
||||
|
||||
void afterSend(bool ok, const char* msg) {
|
||||
_reply_mode = false;
|
||||
if (ok && _sending_to_channel) {
|
||||
_hist_sel = 0;
|
||||
_hist_scroll = 0;
|
||||
@@ -361,7 +383,7 @@ public:
|
||||
_hist_head(0), _hist_count(0),
|
||||
_dm_hist_head(0), _dm_hist_count(0),
|
||||
_dm_hist_sel(-1), _dm_hist_scroll(0),
|
||||
_ctx_dirty(false) {
|
||||
_ctx_dirty(false), _reply_mode(false) {
|
||||
memset(_ch_unread, 0, sizeof(_ch_unread));
|
||||
}
|
||||
|
||||
@@ -571,9 +593,11 @@ public:
|
||||
const DmHistEntry& e = _dm_hist[ring_pos];
|
||||
const char* sender = e.outgoing ? "Me" : filtered_name;
|
||||
int dm_count = dmHistCountForContact(_sel_contact.id.pub_key);
|
||||
return _dm_fs.render(display, sender, e.text,
|
||||
_dm_hist_sel < dm_count - 1,
|
||||
_dm_hist_sel > 0);
|
||||
int ret = _dm_fs.render(display, sender, e.text,
|
||||
_dm_hist_sel < dm_count - 1,
|
||||
_dm_hist_sel > 0);
|
||||
if (_ctx_menu.active) _ctx_menu.render(display);
|
||||
return ret;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
@@ -640,6 +664,7 @@ public:
|
||||
display.setCursor(cbx + 2, cby);
|
||||
display.print(ctxt);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (_ctx_menu.active) _ctx_menu.render(display);
|
||||
return dm_count > 0 ? 500 : 2000;
|
||||
|
||||
} else if (_phase == CHANNEL_HIST) {
|
||||
@@ -658,9 +683,11 @@ public:
|
||||
strncpy(fsender, "?", sizeof(fsender));
|
||||
strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0';
|
||||
}
|
||||
return _fs.render(display, fsender, fmsg,
|
||||
_hist_sel < fs_hist_count - 1,
|
||||
_hist_sel > 0);
|
||||
int ret = _fs.render(display, fsender, fmsg,
|
||||
_hist_sel < fs_hist_count - 1,
|
||||
_hist_sel > 0);
|
||||
if (_ctx_menu.active) _ctx_menu.render(display);
|
||||
return ret;
|
||||
}
|
||||
return 2000;
|
||||
}
|
||||
@@ -745,13 +772,22 @@ public:
|
||||
display.setCursor(cbx + 1, cby);
|
||||
display.print(ctxt);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (_ctx_menu.active) _ctx_menu.render(display);
|
||||
|
||||
} else if (_phase == KEYBOARD) {
|
||||
return _kb.render(display);
|
||||
|
||||
} else { // MSG_PICK
|
||||
char title[24];
|
||||
if (_sending_to_channel) {
|
||||
if (_reply_mode) {
|
||||
int rlen = (int)strlen(_reply_prefix) - 4; // exclude "@[" and "] "
|
||||
if (rlen < 0) rlen = 0;
|
||||
if (rlen > 20) rlen = 20;
|
||||
char nick_raw[32], nick_trans[32];
|
||||
snprintf(nick_raw, sizeof(nick_raw), "%.*s", rlen, _reply_prefix + 2);
|
||||
display.translateUTF8ToBlocks(nick_trans, nick_raw, sizeof(nick_trans));
|
||||
snprintf(title, sizeof(title), "RE:%s", nick_trans);
|
||||
} else if (_sending_to_channel) {
|
||||
ChannelDetails ch;
|
||||
the_mesh.getChannel(_sel_channel_idx, ch);
|
||||
snprintf(title, sizeof(title), "%.23s", ch.name);
|
||||
@@ -938,6 +974,16 @@ public:
|
||||
} else if (_phase == DM_HIST) {
|
||||
int dm_count = dmHistCountForContact(_sel_contact.id.pub_key);
|
||||
if (_dm_fs.active) {
|
||||
if (_ctx_menu.active) {
|
||||
auto res = _ctx_menu.handleInput(c);
|
||||
if (res == PopupMenu::SELECTED) {
|
||||
_dm_fs.active = false;
|
||||
startReply(false);
|
||||
} else if (res != PopupMenu::NONE) {
|
||||
_ctx_menu.active = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
auto res = _dm_fs.handleInput(c);
|
||||
if (res == FullscreenMsgView::PREV) {
|
||||
if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; }
|
||||
@@ -945,6 +991,22 @@ public:
|
||||
if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; }
|
||||
} else if (res == FullscreenMsgView::CLOSE) {
|
||||
_dm_fs.active = false;
|
||||
} else if (res == FullscreenMsgView::REPLY) {
|
||||
int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
|
||||
if (ring_pos >= 0 && !_dm_hist[ring_pos].outgoing) {
|
||||
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _sel_contact.name);
|
||||
_ctx_menu.begin("Options", 1);
|
||||
_ctx_menu.addItem("Reply");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (_ctx_menu.active) {
|
||||
auto res = _ctx_menu.handleInput(c);
|
||||
if (res == PopupMenu::SELECTED) {
|
||||
startReply(false);
|
||||
} else if (res != PopupMenu::NONE) {
|
||||
_ctx_menu.active = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -977,10 +1039,29 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) {
|
||||
int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel);
|
||||
if (ring_pos >= 0 && !_dm_hist[ring_pos].outgoing) {
|
||||
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _sel_contact.name);
|
||||
_ctx_menu.begin("Options", 1);
|
||||
_ctx_menu.addItem("Reply");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} else if (_phase == CHANNEL_HIST) {
|
||||
int ch_hist_count = histCountForChannel(_sel_channel_idx);
|
||||
if (_fs.active) {
|
||||
if (_ctx_menu.active) {
|
||||
auto res = _ctx_menu.handleInput(c);
|
||||
if (res == PopupMenu::SELECTED) {
|
||||
_fs.active = false;
|
||||
startReply(true);
|
||||
} else if (res != PopupMenu::NONE) {
|
||||
_ctx_menu.active = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
auto res = _fs.handleInput(c);
|
||||
if (res == FullscreenMsgView::PREV) {
|
||||
if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); }
|
||||
@@ -988,6 +1069,21 @@ public:
|
||||
if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); }
|
||||
} else if (res == FullscreenMsgView::CLOSE) {
|
||||
_fs.active = false;
|
||||
} else if (res == FullscreenMsgView::REPLY) {
|
||||
int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel);
|
||||
if (ring_pos >= 0 && buildChannelReplyPrefix(_hist[ring_pos].text)) {
|
||||
_ctx_menu.begin("Options", 1);
|
||||
_ctx_menu.addItem("Reply");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (_ctx_menu.active) {
|
||||
auto res = _ctx_menu.handleInput(c);
|
||||
if (res == PopupMenu::SELECTED) {
|
||||
startReply(true);
|
||||
} else if (res != PopupMenu::NONE) {
|
||||
_ctx_menu.active = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1018,13 +1114,22 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) {
|
||||
int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel);
|
||||
if (ring_pos >= 0 && buildChannelReplyPrefix(_hist[ring_pos].text)) {
|
||||
_ctx_menu.begin("Options", 1);
|
||||
_ctx_menu.addItem("Reply");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} else if (_phase == KEYBOARD) {
|
||||
auto res = _kb.handleInput(c);
|
||||
if (res == KeyboardWidget::CANCELLED) {
|
||||
_phase = MSG_PICK;
|
||||
} else if (res == KeyboardWidget::DONE) {
|
||||
if (_kb.len > 0) {
|
||||
int min_len = _reply_mode ? (int)strlen(_reply_prefix) : 0;
|
||||
if (_kb.len > min_len) {
|
||||
char expanded[KB_MAX_LEN + 1];
|
||||
expandMsg(_kb.buf, expanded, sizeof(expanded));
|
||||
bool ok = sendText(expanded);
|
||||
@@ -1036,6 +1141,7 @@ public:
|
||||
} else { // MSG_PICK
|
||||
int total_msg_items = 1 + _active_msg_count;
|
||||
if (c == KEY_CANCEL) {
|
||||
_reply_mode = false;
|
||||
_phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST;
|
||||
return true;
|
||||
}
|
||||
@@ -1051,7 +1157,7 @@ public:
|
||||
}
|
||||
if (c == KEY_ENTER) {
|
||||
if (_msg_sel == 0) {
|
||||
_kb.begin();
|
||||
_kb.begin(_reply_mode ? _reply_prefix : "");
|
||||
kbAddSensorPlaceholders(_kb, &sensors);
|
||||
_phase = KEYBOARD;
|
||||
return true;
|
||||
@@ -1060,7 +1166,13 @@ public:
|
||||
int slot = _active_msgs[_msg_sel - 1];
|
||||
const char* tmpl = p ? p->custom_msgs[slot] : "OK";
|
||||
char msg[140];
|
||||
expandMsg(tmpl, msg, sizeof(msg));
|
||||
if (_reply_mode) {
|
||||
char body[140];
|
||||
expandMsg(tmpl, body, sizeof(body));
|
||||
snprintf(msg, sizeof(msg), "%s%s", _reply_prefix, body);
|
||||
} else {
|
||||
expandMsg(tmpl, msg, sizeof(msg));
|
||||
}
|
||||
bool ok = sendText(msg);
|
||||
afterSend(ok, msg);
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user