Fix twatch tilt raise lock screen functionality. Implement unread count on tw_picker. Fix channel missage history and full message view

This commit is contained in:
Pelgraine
2026-07-07 06:03:12 +10:00
parent 32d0055e93
commit b8b3353cf3
6 changed files with 201 additions and 82 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
#define FIRMWARE_VER_CODE 11
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "4 July 2026"
#define FIRMWARE_BUILD_DATE "7 July 2026"
#endif
#ifndef FIRMWARE_VERSION
@@ -182,7 +182,58 @@ public:
// Sanitize emoji: replace UTF-8 emoji sequences with single-byte escape codes
// The text already contains "Sender: message" format
#if defined(LILYGO_TWATCH_S3_PLUS)
// Watch build: drop emoji entirely instead of storing sprite escape bytes.
// The watch UI has no sprite rendering and no room for emoji at 120x120
// virtual. Non-emoji UTF-8 (accented letters etc.) passes through
// unchanged, the same as emojiSanitize does.
{
const uint8_t* s = (const uint8_t*)text;
int srcLen = (int)strlen(text);
int si = 0, di = 0;
while (si < srcLen && di < CHANNEL_MSG_TEXT_LEN - 1) {
uint8_t b = s[si];
if (b < 0x80) { msg->text[di++] = (char)b; si++; continue; }
int consumed;
uint32_t cp = emojiDecodeUtf8(s + si, srcLen - si, &consumed);
if (cp == 0xFE0F) { si += consumed; continue; } // variation selector
if (cp == 0xFFFD) { si += consumed; continue; } // invalid UTF-8 -- skip
bool isEmoji = false;
for (int e = 0; e < EMOJI_COUNT; e++) {
if (EMOJI_CODEPOINTS[e].cp == cp) {
if (EMOJI_CODEPOINTS[e].cp2 != 0) {
// two-codepoint emoji: only a match when the pair matches
int consumed2;
if (si + consumed < srcLen) {
uint32_t cp2 = emojiDecodeUtf8(s + si + consumed, srcLen - si - consumed, &consumed2);
if (cp2 == EMOJI_CODEPOINTS[e].cp2) {
si += consumed + consumed2;
isEmoji = true;
break;
}
}
continue;
}
si += consumed;
isEmoji = true;
break;
}
}
if (!isEmoji) {
for (int a = 0; a < EMOJI_ALIAS_COUNT; a++) {
if (EMOJI_ALIASES[a].cp == cp) { si += consumed; isEmoji = true; break; }
}
}
if (isEmoji) continue; // dropped
if (di + consumed >= CHANNEL_MSG_TEXT_LEN) break;
for (int k = 0; k < consumed; k++) msg->text[di++] = (char)s[si + k];
si += consumed;
}
msg->text[di] = 0;
}
#else
emojiSanitize(text, msg->text, CHANNEL_MSG_TEXT_LEN);
#endif
if (_msgCount < CHANNEL_MSG_HISTORY_SIZE) {
_msgCount++;
@@ -221,6 +272,23 @@ public:
}
int getMessageCount() const { return _msgCount; }
// Read-only access to stored messages for a given channel, by recency.
// n = 0 returns the newest message on that channel, n = 1 the next newest,
// and so on. Returns nullptr when fewer than n + 1 messages exist.
const ChannelMessage* getChannelMsgByRecency(uint8_t channel_idx, int n) const {
if (n < 0 || _msgCount == 0) return nullptr;
int seen = 0;
for (int i = 0; i < _msgCount; i++) {
int idx = _newestIdx - i;
while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE;
const ChannelMessage& m = _messages[idx];
if (!m.valid || m.channel_idx != channel_idx) continue;
if (seen == n) return &m;
seen++;
}
return nullptr;
}
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
void setViewChannelIdx(uint8_t idx) {
+28 -15
View File
@@ -478,8 +478,15 @@ public:
char timeBuf[6];
sprintf(timeBuf, "%02d:%02d", hrs, mins);
int clockX = (display.width() - lcd->smallTextWidth(timeBuf)) / 2;
display.setColor(DisplayDriver::LIGHT);
lcd->printSmallFont(clockX, HOME_HDR_Y, timeBuf);
// Long node names: keep the clock centred when there is room,
// otherwise push it right so it starts just past the name. When even
// that would run into the unread count, hide the clock instead.
int nameEndX = lcd->smallTextWidth(filtered_name) + 3;
if (clockX < nameEndX) clockX = nameEndX;
if (clockX + lcd->smallTextWidth(timeBuf) + 3 <= numX) {
display.setColor(DisplayDriver::LIGHT);
lcd->printSmallFont(clockX, HOME_HDR_Y, timeBuf);
}
}
}
#else
@@ -1459,7 +1466,9 @@ public:
"Jul","Aug","Sep","Oct","Nov","Dec"};
sprintf(buf, "%s %d %s", days[tmv.tm_wday], tmv.tm_mday, mons[tmv.tm_mon]);
display.setTextSize(2);
display.setColor(DisplayDriver::GREEN); // light grey on the watch palette
display.drawTextCentered(display.width() / 2, display.height() / 2 + 12, buf);
display.setColor(DisplayDriver::LIGHT); // restore for the battery line
}
// battery % and unread count
@@ -1657,6 +1666,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
tw_picker = new TWatchChannelPicker(_display);
tw_channel = new TWatchChannelScreen(_display);
tw_keyboard = new TWatchKeyboardScreen(_display);
((TWatchChannelPicker*)tw_picker)->setStore((ChannelScreen*)channel_screen);
((TWatchChannelScreen*)tw_channel)->setStore((ChannelScreen*)channel_screen);
#endif
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
#ifdef MECK_AUDIO_VARIANT
@@ -1774,9 +1785,6 @@ void UITask::msgRead(int msgcount) {
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount,
const uint8_t* path, int8_t snr, uint8_t scope_idx) {
_msgcount = msgcount;
#ifdef TWATCH_COMPOSE_ENABLED
if (tw_channel) ((TWatchChannelScreen*)tw_channel)->notifyMsg(from_name, text);
#endif
// --- Dedup: suppress retry spam (same sender + text within 60s) ---
uint32_t nameH = simpleHash(from_name);
@@ -1930,6 +1938,10 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
// app is connected (they'll see it there), mark as read immediately
if ((isOnChannelScreen() &&
((ChannelScreen *) channel_screen)->getViewChannelIdx() == channel_idx) ||
#ifdef TWATCH_COMPOSE_ENABLED
(curr == tw_channel &&
((TWatchChannelScreen*)tw_channel)->getChannelIdx() == channel_idx) ||
#endif
hasConnection()) {
((ChannelScreen *) channel_screen)->markChannelRead(channel_idx);
}
@@ -2309,6 +2321,7 @@ if (curr) curr->poll();
if (curr == tw_picker) {
TWatchChannelPicker* picker = (TWatchChannelPicker*)tw_picker;
if (picker->isConfirmed()) {
((TWatchChannelScreen*)tw_channel)->setSelfName(the_mesh.getNodePrefs()->node_name);
((TWatchChannelScreen*)tw_channel)->activate(picker->getSelectedChannelIdx(),
picker->getSelectedChannelName());
setCurrScreen(tw_channel);
@@ -2347,7 +2360,7 @@ if (curr) curr->poll();
the_mesh.sendGroupMessage(ts, ch.channel, the_mesh.getNodeName(),
sendText, strlen(sendText));
showAlert("Sent!", 800);
((TWatchChannelScreen*)tw_channel)->addSentMsg(sendText);
addSentChannelMessage(kb->getChannelIdx(), the_mesh.getNodePrefs()->node_name, sendText);
}
kb->clearOutBuf();
setCurrScreen(tw_channel);
@@ -2541,17 +2554,17 @@ if (curr) curr->poll();
if (millis() > _auto_off) {
_display->turnOff();
}
#endif
#if defined(LILYGO_TWATCH_S3_PLUS)
// Raise-to-wake: a wrist-raise (BMA423 tilt) while the display is off turns
// it back on showing the clock screen and restarts the idle timer.
if (!_display->isOn() && board.tiltFired()) {
_display->turnOn();
setCurrScreen(lock_screen);
_auto_off = millis() + AUTO_OFF_MILLIS;
}
#endif
}
#if defined(LILYGO_TWATCH_S3_PLUS)
// Raise-to-wake: a wrist-raise (BMA423 tilt) while the display is off turns
// it back on showing the clock screen and restarts the idle timer.
if (_display != NULL && !_display->isOn() && board.tiltFired()) {
_display->turnOn();
setCurrScreen(lock_screen);
_auto_off = millis() + AUTO_OFF_MILLIS;
}
#endif
// Auto-lock idle timer — runs regardless of display on/off state
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
+4
View File
@@ -55,6 +55,10 @@ public:
buffer.setFont(&fonts::Font0);
return w;
}
// Enable/disable horizontal text wrap on the sprite buffer. The compose
// ticker turns wrap off so a long scrolling line is clipped at the screen
// edge instead of wrapping onto the rows below.
void setTextWrap(bool en) { buffer.setTextWrap(en, false); }
#endif
void setCursor(int x, int y) override;
void print(const char* str) override;
@@ -13,10 +13,11 @@
// top half to move the highlight up, the bottom half
// to move it down; long-press selects the highlighted
// channel; the back arrow (top-left) returns home.
// TWatchChannelScreen shows the last TW_INBOX_SIZE incoming messages for
// the selected channel (live, in-RAM, no history).
// Tap the compose bar to open the keyboard; back
// arrow returns home.
// TWatchChannelScreen -- shows the most recent messages (sent + received)
// for the selected channel, read live from the shared
// ChannelScreen history store. Tap a line to ticker-
// scroll it; tap the compose bar to open the keyboard;
// back arrow returns home.
// TWatchKeyboardScreen — on-screen QWERTY. Bottom-left mode key cycles
// lower -> UPPER -> SYM. Bottom row is mode | space |
// enter | backspace. Enter sends on the channel; back
@@ -25,8 +26,8 @@
// Each screen reads getTouch() itself in poll() and does its own release-edge
// detection, so actions fire on touch release (finger up) — this keeps the
// held finger from carrying into the next screen after a transition. Send/exit
// are delegated to UITask via the consume/flag pattern, so this header has no
// dependency on MyMesh / BaseChatMesh.
// are delegated to UITask via the consume/flag pattern; message history and
// unread counts are read from the shared ChannelScreen store.
// =============================================================================
#ifdef TWATCH_COMPOSE_ENABLED
@@ -36,12 +37,11 @@
#include <helpers/ui/LGFXDisplay.h>
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include "ChannelScreen.h" // shared message history store (-I examples/companion_radio/ui-new)
// ---- Tunables ---------------------------------------------------------------
#define TW_LONG_PRESS_MS 600 // hold to select a channel in the picker
#define TW_OUT_BUF_LEN 134 // MeshCore per-channel msg cap (~133) + NUL
#define TW_INBOX_SIZE 5 // messages kept/shown on the channel screen
#define TW_INBOX_TEXT_LEN 96
#define TW_TICKER_MS_PER_PX 20 // channel-screen ticker scroll speed (ms per pixel)
#define TW_CH_NAME_LEN 32
#define TW_PICKER_MAX 20 // matches MAX_GROUP_CHANNELS
@@ -57,6 +57,7 @@ class TWatchChannelPicker : public UIScreen {
uint8_t _numChannels;
uint8_t _highlighted;
uint8_t _scrollTop;
ChannelScreen* _store; // unread badge source
// touch edge state
bool _touchDown;
@@ -94,11 +95,15 @@ class TWatchChannelPicker : public UIScreen {
public:
TWatchChannelPicker(DisplayDriver* display)
: _display(display), _numChannels(0), _highlighted(0), _scrollTop(0),
_store(nullptr),
_touchDown(false), _downX(0), _downY(0), _downAt(0),
_confirmed(false), _wantsExit(false) {
memset(_channels, 0, sizeof(_channels));
}
// Shared history store, used for the per-channel unread badges.
void setStore(ChannelScreen* store) { _store = store; }
// Called by UITask before showing the screen.
void beginChannelSelect() {
_numChannels = 0; _highlighted = 0; _scrollTop = 0;
@@ -170,7 +175,18 @@ public:
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.drawTextEllipsized(3, y + 2, W - 6, _channels[ci].name);
int nameMaxW = W - 6;
int unread = _store ? _store->getUnreadForChannel(_channels[ci].idx) : 0;
if (unread > 0) {
char cnt[8];
snprintf(cnt, sizeof(cnt), "%d", unread > 99 ? 99 : unread);
int cw = display.getTextWidth(cnt);
display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::BLUE);
display.drawTextRightAlign(W - 3, y + 2, cnt);
display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::LIGHT);
nameMaxW = W - 6 - cw - 4;
}
display.drawTextEllipsized(3, y + 2, nameMaxW, _channels[ci].name);
y += ROW_H;
}
return 500;
@@ -182,13 +198,10 @@ public:
// =============================================================================
class TWatchChannelScreen : public UIScreen {
DisplayDriver* _display;
ChannelScreen* _store; // shared message history store
uint8_t _channelIdx;
char _channelName[TW_CH_NAME_LEN];
struct InboxEntry { char text[TW_INBOX_TEXT_LEN]; bool valid; bool isSent; };
InboxEntry _inbox[TW_INBOX_SIZE];
uint8_t _inboxNewest;
uint8_t _inboxCount;
char _selfPrefix[TW_CH_NAME_LEN + 2]; // "NodeName: " -- marks sent lines
bool _touchDown;
int _downX, _downY;
@@ -196,23 +209,35 @@ class TWatchChannelScreen : public UIScreen {
bool _wantsCompose;
bool _wantsExit;
int _selectedSlot; // ring slot shown as ticker, -1 = none
int _selectedN; // recency index shown as ticker, -1 = none
unsigned long _tickerStartMs;
const void* _lastNewest; // newest store entry seen last render
uint32_t _lastNewestTs; // (a change dismisses the ticker)
static const int HEADER_H = 14;
static const int COMPOSE_BAR_H = 18;
static const int MSG_LINE_H = 11;
static const int MSG_TOP = HEADER_H + 2;
void pushEntry(const char* text, bool sent) {
_inboxNewest = (_inboxCount == 0) ? 0 : (uint8_t)((_inboxNewest + 1) % TW_INBOX_SIZE);
InboxEntry& e = _inbox[_inboxNewest];
if (text) { strncpy(e.text, text, TW_INBOX_TEXT_LEN - 1); e.text[TW_INBOX_TEXT_LEN - 1] = 0; }
else { e.text[0] = 0; }
e.valid = true;
e.isSent = sent;
if (_inboxCount < TW_INBOX_SIZE) _inboxCount++;
_selectedSlot = -1; // a new message dismisses any open ticker
int rowsThatFit() const {
int h = _display ? _display->height() : 120;
int rows = (h - COMPOSE_BAR_H - 1 - MSG_TOP) / MSG_LINE_H;
return rows < 1 ? 1 : rows;
}
// Store messages available for this channel, capped at what fits on screen.
int visibleCount() const {
if (!_store) return 0;
int maxRows = rowsThatFit();
int n = 0;
while (n < maxRows && _store->getChannelMsgByRecency(_channelIdx, n)) n++;
return n;
}
// Sent messages are stored with path_len 0 and a "NodeName: " prefix.
bool isSent(const ChannelScreen::ChannelMessage* m) const {
return m->path_len == 0 && _selfPrefix[0] != 0 &&
strncmp(m->text, _selfPrefix, strlen(_selfPrefix)) == 0;
}
void drawMsgLine(DisplayDriver& display, int y, const char* text, bool sent) {
@@ -227,7 +252,7 @@ class TWatchChannelScreen : public UIScreen {
display.drawTextRightAlign(W - 2, y, text);
return;
}
char buf[TW_INBOX_TEXT_LEN + 4];
char buf[CHANNEL_MSG_TEXT_LEN + 4];
strncpy(buf, text, sizeof(buf) - 4);
buf[sizeof(buf) - 4] = 0;
int ellW = display.getTextWidth("...");
@@ -249,49 +274,51 @@ class TWatchChannelScreen : public UIScreen {
int period = textW + 24; // full text width + trailing gap
unsigned long elapsed = millis() - _tickerStartMs;
int off = (int)((elapsed / TW_TICKER_MS_PER_PX) % (unsigned long)period);
// Wrap off while the marquee draws: a long line must clip at the screen
// edge, not wrap onto the rows below. Restored straight after.
((LGFXDisplay*)_display)->setTextWrap(false);
display.setCursor(2 - off, y);
display.print(text);
display.setCursor(2 - off + period, y);
display.print(text);
((LGFXDisplay*)_display)->setTextWrap(true);
}
public:
TWatchChannelScreen(DisplayDriver* display)
: _display(display), _channelIdx(0),
_inboxNewest(0), _inboxCount(0),
: _display(display), _store(nullptr), _channelIdx(0),
_touchDown(false), _downX(0), _downY(0),
_wantsCompose(false), _wantsExit(false),
_selectedSlot(-1), _tickerStartMs(0) {
_selectedN(-1), _tickerStartMs(0),
_lastNewest(nullptr), _lastNewestTs(0) {
_channelName[0] = 0;
memset(_inbox, 0, sizeof(_inbox));
_selfPrefix[0] = 0;
}
// Shared history store this screen renders from.
void setStore(ChannelScreen* store) { _store = store; }
// Own node name, used to right-align sent lines ("NodeName: text").
void setSelfName(const char* name) {
if (!name || !name[0]) { _selfPrefix[0] = 0; return; }
snprintf(_selfPrefix, sizeof(_selfPrefix), "%s: ", name);
}
// Switch to a channel (called when a channel is selected in the picker).
// Opening a channel marks its messages as read.
void activate(uint8_t idx, const char* name) {
_channelIdx = idx;
strncpy(_channelName, name ? name : "", TW_CH_NAME_LEN - 1);
_channelName[TW_CH_NAME_LEN - 1] = 0;
_inboxNewest = 0; _inboxCount = 0;
memset(_inbox, 0, sizeof(_inbox));
_touchDown = false; _wantsCompose = false; _wantsExit = false;
_selectedSlot = -1;
_selectedN = -1;
_lastNewest = nullptr; _lastNewestTs = 0;
if (_store) _store->markChannelRead(_channelIdx);
}
uint8_t getChannelIdx() const { return _channelIdx; }
const char* getChannelName() const { return _channelName; }
// Fed by UITask::newMsg for every incoming message; kept only if it matches
// this channel by name.
void notifyMsg(const char* from, const char* text) {
if (!from || strcmp(from, _channelName) != 0) return;
pushEntry(text, false);
}
// Called by UITask after a message is sent on this channel.
void addSentMsg(const char* text) {
pushEntry(text, true);
}
bool wantsCompose() const { return _wantsCompose; }
void acknowledgeCompose() { _wantsCompose = false; }
bool wantsExit() const { return _wantsExit; }
@@ -308,20 +335,16 @@ public:
} else if (!now && _touchDown) {
_touchDown = false;
int H = _display->height();
if (_downX < 20 && _downY < HEADER_H) { _selectedSlot = -1; _wantsExit = true; return; } // back arrow
if (_downY >= H - COMPOSE_BAR_H) { _selectedSlot = -1; _wantsCompose = true; return; } // compose bar
if (_downX < 20 && _downY < HEADER_H) { _selectedN = -1; _wantsExit = true; return; } // back arrow
if (_downY >= H - COMPOSE_BAR_H) { _selectedN = -1; _wantsCompose = true; return; } // compose bar
// message area -> tap to open/close ticker
if (_downY >= MSG_TOP && _downY < H - COMPOSE_BAR_H) {
int visualRow = (_downY - MSG_TOP) / MSG_LINE_H;
if (visualRow >= 0 && visualRow < _inboxCount) {
int i = (_inboxCount - 1) - visualRow;
int slot = (int)_inboxNewest - i;
while (slot < 0) slot += TW_INBOX_SIZE;
slot %= TW_INBOX_SIZE;
if (_inbox[slot].valid) {
if (_selectedSlot == slot) _selectedSlot = -1;
else { _selectedSlot = slot; _tickerStartMs = millis(); }
}
int count = visibleCount();
if (visualRow >= 0 && visualRow < count) {
int n = (count - 1) - visualRow; // top row = oldest visible
if (_selectedN == n) _selectedN = -1;
else { _selectedN = n; _tickerStartMs = millis(); }
}
}
}
@@ -340,22 +363,29 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, HEADER_H - 2, W, 1);
const int areaBottom = H - COMPOSE_BAR_H - 1;
// A new message (sent or received) shifts the rows, so dismiss any open
// ticker when the newest store entry for this channel changes.
const ChannelScreen::ChannelMessage* newest =
_store ? _store->getChannelMsgByRecency(_channelIdx, 0) : nullptr;
if (newest != _lastNewest || (newest && newest->timestamp != _lastNewestTs)) {
_lastNewest = newest;
_lastNewestTs = newest ? newest->timestamp : 0;
_selectedN = -1;
}
if (_inboxCount == 0) {
int count = visibleCount();
if (count == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(2, MSG_TOP);
display.print("(no messages)");
} else {
int y = MSG_TOP;
for (int i = _inboxCount - 1; i >= 0; i--) { // oldest first (top) -> newest (bottom)
int idx = (int)_inboxNewest - i;
while (idx < 0) idx += TW_INBOX_SIZE;
const InboxEntry& e = _inbox[idx];
if (!e.valid) continue;
if (y + MSG_LINE_H > areaBottom) break;
if (idx == _selectedSlot) drawTicker(display, y, e.text, e.isSent);
else drawMsgLine(display, y, e.text, e.isSent);
for (int n = count - 1; n >= 0; n--) { // oldest visible (top) -> newest (bottom)
const ChannelScreen::ChannelMessage* m = _store->getChannelMsgByRecency(_channelIdx, n);
if (!m) continue;
bool sent = isSent(m);
if (n == _selectedN) drawTicker(display, y, m->text, sent);
else drawMsgLine(display, y, m->text, sent);
y += MSG_LINE_H;
}
}
@@ -364,7 +394,7 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, H - COMPOSE_BAR_H, W, COMPOSE_BAR_H - 1);
display.drawTextEllipsized(3, H - COMPOSE_BAR_H + 4, W - 6, "Tap to compose");
return (_selectedSlot >= 0) ? 60 : 500;
return (_selectedN >= 0) ? 60 : 500;
}
};
@@ -14,6 +14,10 @@ void TWatchS3PlusBoard::begin() {
_accel->configAccelerometer(OperationMode::NORMAL, AccelFullScaleRange::FS_2G,
50.0f, AccelBandwidth::OSR2_AVG2, AccelPerfMode::CIC_AVG_MODE);
_accel->enableTiltDetector(true, true);
// INT1 pin electrical config: level trigger, active high, push-pull,
// output enabled. INT1_IO_CTRL resets to output-disabled, so without
// this the pin never drives and INPUT_PULLDOWN reads low forever.
_accel->setInterruptPinConfig(InterruptPinMap::PIN1, false, false, true, false);
pinMode(PIN_ACCEL_IRQ, INPUT_PULLDOWN);
}