From 8324d0c6d18d2f8f26394f4548f56f6a723ab09f Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:32:54 +0200 Subject: [PATCH] feat(ui-new): message alert setting -- banner, corner envelope or none Display > Message alert: Normal keeps the sliding banner; Compact shows an envelope with the unread count beside the battery (in the header line where there's no status row), blinking for 2 s; Off shows nothing. The blink only ever brings the next frame earlier, so it no longer holds back the UI's animations while it runs. Co-Authored-By: Claude Opus 5.5 --- docs/solo/getting-started.md | 5 ++ docs/solo/settings.md | 2 +- examples/companion_radio/DataStore.cpp | 1 + examples/companion_radio/NodePrefs.h | 6 ++ .../companion_radio/ui-core/SettingsSchema.h | 6 ++ examples/companion_radio/ui-lvgl/UITask.cpp | 1 + examples/companion_radio/ui-new/UITask.cpp | 64 +++++++++++++++++-- examples/companion_radio/ui-new/UITask.h | 34 ++++++++-- examples/companion_radio/ui-new/icons.h | 40 ++++++++++++ 9 files changed, 149 insertions(+), 10 deletions(-) diff --git a/docs/solo/getting-started.md b/docs/solo/getting-started.md index 7cf141e5..148282eb 100644 --- a/docs/solo/getting-started.md +++ b/docs/solo/getting-started.md @@ -72,6 +72,11 @@ the top of the screen and slide away after a moment; on e-ink they just appear and go. A message's lasts two seconds. Typing on the keyboard isn't interrupted. +Settings › Display › Message alert picks how a new message shows: **Normal** +(the banner), **Compact** (an envelope and a count in the top-right corner, +blinking for a moment, covering nothing else) or **Off** (no alert; the sound +and waking the screen still follow their own settings). + The battery sits at the top right, as an icon, a percentage or the voltage (Settings › Display). A small bolt beside it means external power is connected over USB. diff --git a/docs/solo/settings.md b/docs/solo/settings.md index 8fda38b1..642009a7 100644 --- a/docs/solo/settings.md +++ b/docs/solo/settings.md @@ -7,7 +7,7 @@ board supports are shown. | Section | What's there | | ------- | ------------ | -| **Display** | Brightness, auto-off, wake on message, lock screen, the lock clock's look and the [PIN](./lock.md), battery as icon / % / volts, time zone, clock format and seconds; on e-ink also rotation and full refresh | +| **Display** | Brightness, auto-off, wake on message, the message alert (banner, corner envelope or off), lock screen, the lock clock's look and the [PIN](./lock.md), battery as icon / % / volts, time zone, clock format and seconds; on e-ink also rotation and full refresh | | **Sound** | Buzzer on / off / auto (quiet while the app is connected), volume, quiet hours, the melody for messages, channels and adverts | | **Home Pages** | Order of the home pages, and which are shown | | **Radio** | TX power, preset, frequency, SF / BW / CR, saved presets, Auto pwr, the scope list | diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 7801d87c..3ed79fd5 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -333,6 +333,7 @@ static void sanitize(NodePrefs& p) { if (!battery::validCurve(p.batt_curve_mv)) memset(p.batt_curve_mv, 0, sizeof(p.batt_curve_mv)); flag(p.lock_compact, 0); flag(p.ble_off, 0); + if (p.msg_alert > NodePrefs::MSG_ALERT_OFF) p.msg_alert = NodePrefs::MSG_ALERT_NORMAL; if (!p.ch_scope_follow) { for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) if (p.ch_scope_idx[i] == 0) p.ch_scope_idx[i] = NodePrefs::CH_SCOPE_DEFAULT; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 6c982497..b656d9a0 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -532,6 +532,12 @@ struct NodePrefs { // persisted to file // default" on load (DataStore sanitize). uint8_t ch_scope_follow; + // ── New-message alert (ui-new) ───────────────────────────────────────── + // How a message arriving shows: the banner from the top, an envelope with + // a count in the corner, or nothing (the sound and the wake still happen). + static const uint8_t MSG_ALERT_NORMAL = 0, MSG_ALERT_COMPACT = 1, MSG_ALERT_OFF = 2; + uint8_t msg_alert; + // Single source of truth for the live-share option tables (shared by the Map // UI labels and the auto-send engine in UITask). static const uint8_t LOC_SHARE_MOVE_COUNT = 4; diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index a1bb3208..82bf971d 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -131,6 +131,10 @@ static void optBattDisplay(uint8_t v, char* b, int n, const NodePrefs&) { static const char* L[] = { "Icon", "Percent", "Voltage" }; snprintf(b, n, "%s", L[v < 3 ? v : 0]); } +static void optMsgAlert(uint8_t v, char* b, int n, const NodePrefs&) { + static const char* const L[] = { "Normal", "Compact", "Off" }; + snprintf(b, n, "%s", L[v < 3 ? v : 0]); +} static void optLockLook(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", v ? "Compact" : "Big"); } static void optExpiry(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", NodePrefs::contactExpiryLabel(v)); @@ -196,6 +200,7 @@ static const Setting ALL[] = { IDX("Brightness", nullptr, SEC_DISPLAY, display_brightness, 5, optBrightness, applyDisplay), MAP("Screen off after", "Without a touch", SEC_DISPLAY, auto_off_secs, AUTO_OFF, optAutoOff, nullptr), SW("Wake on message", "Screen on for new messages", SEC_DISPLAY, msg_wake, nullptr), + IDX("Message alert", "Banner, corner envelope or none", SEC_DISPLAY, msg_alert, 3, optMsgAlert, nullptr), SW("Lock screen", "Slide to unlock after sleep", SEC_DISPLAY, auto_lock, nullptr), IDX("Lock clock", "Big, or compact with the clock fields", SEC_DISPLAY, lock_compact, 2, optLockLook, nullptr), IDX("Battery display", "In the status bar", SEC_DISPLAY, batt_display_mode, 3, optBattDisplay, nullptr), @@ -234,6 +239,7 @@ static const int COUNT = (int)(sizeof(ALL) / sizeof(ALL[0])); static const struct { uint16_t offset; const char* text; } SHORT_LABELS[] = { { NP_OFF(display_brightness), "Bright" }, { NP_OFF(auto_off_secs), "Auto off" }, { NP_OFF(msg_wake), "Msg wake" }, { NP_OFF(auto_lock), "Auto lock" }, + { NP_OFF(msg_alert), "Msg alert" }, { NP_OFF(batt_display_mode), "Batt disp" }, { NP_OFF(low_batt_mv), "Low batt" }, { NP_OFF(gps_interval), "GPS pwr" }, { NP_OFF(clock_12h), "12h clock" }, { NP_OFF(clock_hide_seconds), "Seconds" }, { NP_OFF(units_imperial), "Imperial" }, diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index e37117cb..d2a3c6eb 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -4434,6 +4434,7 @@ void UITask::schemaSection(int i) { if (sec == settings::SEC_SOUND) buildSoundRows(card, true); // On / Off / Auto for (int k = i; k < settings::COUNT && settings::ALL[k].section == sec; k++) { if (settings::ALL[k].offset == offsetof(NodePrefs, lock_compact)) continue; // ui-new's lock look; the slide card has one + if (settings::ALL[k].offset == offsetof(NodePrefs, msg_alert)) continue; // ui-new's alert; the L2 has its own schemaRow(card, k); } } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 1f3eab21..f7a76a2e 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -795,6 +795,16 @@ class HomeScreen : public UIScreen { int x = battLeftX; const int name_min = (reserve_left >= 0) ? reserve_left : display.getCharWidth() * 5; + // Settings > Message alert Compact: the envelope and its count, first by the + // battery; its place is kept through the blink so nothing beside it jumps. + if (const int n = _task->msgBadgeCount()) { + const int ix = x - mailCountWidth(display, n) - ind_gap - 2 * miniIconScale(display); // the icons' boxes pad theirs + if (ix >= name_min) { + if (_task->msgBadgeLit()) drawMailCount(display, ix, 0, ind_h, n); + _task->msgBadgeInBar(); + x = ix; + } + } for (const Sicon& s : icons) { if (!s.active) continue; int ix = x - ind - ind_gap; @@ -2191,6 +2201,15 @@ void UITask::gotoMessagesScreen() { } #ifdef SIM_PLATFORM +void UITask::simMessage(const char* text) { + UiEvent ev = {}; + ev.type = UiEventType::MessageArrived; + ev.kind = UIEventType::channelMessage; + ev.idx = 0; + snprintf(ev.text, sizeof(ev.text), "%s", text); + onMessageArrived(ev); +} + void UITask::simCompose(const char* text) { gotoMessagesScreen(); ((MessagesScreen*)messages_screen)->simCompose(text); @@ -2301,7 +2320,8 @@ void UITask::clearAllDMUnread() { _core->clearAllDMUnread(); } void UITask::showAlert(const char* text, int duration_millis) { snprintf(_alert, sizeof(_alert), "%s", text); - if (!alertShowing()) _alert_t0 = millis(); // a banner already down only changes its text + if (!alertShowing() || _alert_badge) _alert_t0 = millis(); // a banner already down only changes its text + _alert_badge = false; _alert_expiry = (millis() + duration_millis) | 1; // 0 means none } @@ -2360,7 +2380,14 @@ void UITask::onMessageArrived(const UiEvent& ev) { bool in_view = curr == messages_screen && _display && _display->isOn() && !_locked && ((ev.kind == UIEventType::contactMessage && ev.flag && isViewingDM(ev.key)) || (ev.kind == UIEventType::channelMessage && ev.idx >= 0 && isViewingChannel((uint8_t)ev.idx))); - if (!in_view) { + const uint8_t style = _node_prefs ? _node_prefs->msg_alert : NodePrefs::MSG_ALERT_NORMAL; + if (!in_view && style == NodePrefs::MSG_ALERT_COMPACT) { + // The envelope counts the run: each message adds one and blinks it again. + _alert_count = alertShowing() && _alert_badge ? _alert_count + 1 : 1; + _alert_badge = true; + _alert_t0 = millis(); + _alert_expiry = (millis() + BADGE_MS) | 1; + } else if (!in_view && style == NodePrefs::MSG_ALERT_NORMAL) { char alert_buf[80]; snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", ev.text); showAlert(alert_buf, 2000); @@ -2409,6 +2436,7 @@ void UITask::userLedHandler() { // banner instead. Uses the shared wrap scratch (s_wrap_*) -- single-threaded // render path, same contract as the message views. void UITask::renderAlertOverlay() { + if (_alert_badge) { renderMsgBadge(); return; } _display->setTextSize(1); const int lh = _display->getLineHeight(); const int pad = 3; @@ -2436,6 +2464,21 @@ void UITask::renderAlertOverlay() { _display->drawTextCentered(W / 2, y + pad + i * lh, s_wrap_lines[i]); } +// Settings > Message alert Compact: an envelope and how many messages came. On +// Home it sits among the status icons (HomeScreen::renderBatteryIndicator); +// it blinks for a while after each one (not on e-ink), then holds till it goes. +void UITask::renderMsgBadge() { + if (_badge_in_bar) return; // among the Home status icons already + // Elsewhere in the header's line, at its right, clear of the line under it. + DisplayDriver& d = *_display; + const int s = miniIconScale(d), h = d.headerH() - d.sepH(), w = mailCountWidth(d, _alert_count); + const int x = d.width() - w - 2 * s; + d.setColor(DisplayDriver::DARK); + d.fillRect(x - 2 * s, 0, w + 4 * s, h); + d.setColor(DisplayDriver::LIGHT); + if (msgBadgeLit()) drawMailCount(d, x, 0, h, _alert_count); +} + void UITask::setCurrScreen(UIScreen* c) { // Fail safe on a null target: a screen pointer left uninitialised (member // declared + navigator wired, but the `new XScreen()` line forgotten in @@ -2673,6 +2716,14 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_compose(const char* text) { if (g_sim_ui_task_for_js) g_sim_ui_task_for_js->simCompose(text); } +// Sim tests: a channel message arrives, alerted in Settings > Message alert's +// style (0 Normal, 1 Compact, 2 Off). +extern "C" EMSCRIPTEN_KEEPALIVE void sim_message(const char* text, int style) { + if (!g_sim_ui_task_for_js) return; + if (NodePrefs* p = g_sim_ui_task_for_js->getNodePrefs()) p->msg_alert = (uint8_t)style; + g_sim_ui_task_for_js->simMessage(text); +} + // Long-press counterpart -- the host page's own press-and-hold timer (see // web/index.html/mesh.html) calls this instead of sim_enqueue_key() once a // button/key has been held past the same ~1000ms threshold every real @@ -3357,6 +3408,7 @@ void UITask::loop() { // While the prompt is up the password keyboard replaces the lockscreen view PERF_T0(); _display->startFrame(); + _badge_in_bar = false; _kb.beginFrame(); int delay_millis = _kb.render(*_display); if (alertShowing()) renderAlertOverlay(); // "Wrong PIN", and a ringing alarm @@ -3364,10 +3416,11 @@ void UITask::loop() { _display->endFrame(); PERF_T2(); _next_refresh = millis() + delay_millis; - if (alertSliding()) _next_refresh = millis() + 25; + alertFrameCap(); } else if (_locked && refreshDue() && home) { PERF_T0(); _display->startFrame(); + _badge_in_bar = false; if (curr && curr != home && (millis() - ui_started_at < BOOT_SCREEN_MILLIS)) { // Boot splash is still up on a boot-locked device _next_refresh = millis() + curr->render(*_display); @@ -3379,7 +3432,7 @@ void UITask::loop() { // played its melody against a screen that never said what was ringing. if (alertShowing()) { renderAlertOverlay(); - if (alertSliding()) _next_refresh = millis() + 25; + alertFrameCap(); } PERF_T1(); _display->endFrame(); @@ -3387,6 +3440,7 @@ void UITask::loop() { } else if (!_locked && refreshDue() && curr) { PERF_T0(); _display->startFrame(); + _badge_in_bar = false; _kb.beginFrame(); int delay_millis = curr->render(*_display); // Skip the alert overlay (new-message toast) while the keyboard is the @@ -3402,7 +3456,7 @@ void UITask::loop() { // history scrollbar reserve — don't stay stuck behind the alert. Unchanged // frames are skipped by the display CRC, so e-ink isn't thrashed. _next_refresh = millis() + delay_millis; - if (alertSliding()) _next_refresh = millis() + 25; // the slide, a frame at a time + alertFrameCap(); // the slide or the blink, a frame at a time if ((int32_t)(_next_refresh - _alert_expiry) > 0) _next_refresh = _alert_expiry; } else { _next_refresh = millis() + delay_millis; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 8d2ef1d5..20af904e 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -80,6 +80,12 @@ class UITask : public UITaskBase, public UiCoreHost { KeyboardWidget _kb; // shared across all screens — only one active at a time unsigned long _alert_expiry; unsigned long _alert_t0 = 0; // when the banner began to come down + // Settings > Message alert Compact: the alert is an envelope and a count + // in the top-right corner instead of the banner (renderMsgBadge()). + bool _alert_badge = false; + int _alert_count = 0; // messages in this run of the envelope + bool _badge_in_bar = false; // this frame's Home status bar drew it + static const int BADGE_BLINK_MS = 350, BADGE_BLINK_FOR = 2100, BADGE_MS = 3000; int _last_notif_ch_idx; uint8_t _last_notif_dm_prefix[4]; bool _last_notif_dm_valid; @@ -244,6 +250,7 @@ private: // Shared by the normal render path and the lock screen (so a ringing // alarm's label is visible while locked). void renderAlertOverlay(); + void renderMsgBadge(); public: // Lock now, from the Home Power panel (unlocking stays the gesture / PIN prompt). @@ -276,6 +283,7 @@ public: void gotoMessagesScreen(); #ifdef SIM_PLATFORM void simCompose(const char* text); // sim tests: a channel post's keyboard holding `text` + void simMessage(const char* text); // sim tests: a channel message arrives #endif void openContactDM(const ContactInfo& ci); void openChannelHistory(uint8_t channel_idx); // Favourites dial: open a pinned channel @@ -355,12 +363,30 @@ public: // Wrap-safe (millis() rolls over after ~49.7 days). _next_refresh 0 = draw // on the next pass; _alert_expiry 0 = no alert. bool refreshDue() const { return !_next_refresh || (int32_t)(millis() - _next_refresh) >= 0; } - // The banner is on its way in or out (not on e-ink): redraw every frame. - bool alertSliding() const { - return alertShowing() && !_display->isEink() && - ((int32_t)(millis() - _alert_t0) < 150 || (int32_t)(_alert_expiry - millis()) < 150); + // The banner on its way in or out, or the envelope blinking (not on e-ink): + // the ms to the next frame it needs, 0 = none sooner than the screen's own. + int alertFrameMs() const { + if (!alertShowing() || _display->isEink()) return 0; + const int el = (int)(millis() - _alert_t0); + if (_alert_badge) return el < BADGE_BLINK_FOR ? BADGE_BLINK_MS - el % BADGE_BLINK_MS : 0; + return (el < 150 || (int32_t)(_alert_expiry - millis()) < 150) ? 25 : 0; + } + // Brings the next redraw forward to the alert's next frame, never later than + // the screen asked for (its own animation keeps its pace under the blink). + void alertFrameCap() { + const int f = alertFrameMs(); + if (f && (int32_t)(_next_refresh - (millis() + f)) > 0) _next_refresh = millis() + f; } bool alertShowing() const { return _alert_expiry && (int32_t)(_alert_expiry - millis()) > 0; } + // Message alert Compact: how many messages the envelope counts (0 = none + // up), whether it is lit (it blinks a while after each), and the Home status + // bar saying it drew it among its icons, so the overlay stays out. + int msgBadgeCount() const { return alertShowing() && _alert_badge ? _alert_count : 0; } + bool msgBadgeLit() const { + const int el = (int)(millis() - _alert_t0); + return _display->isEink() || el >= BADGE_BLINK_FOR || (el / BADGE_BLINK_MS) % 2 == 0; + } + void msgBadgeInBar() { _badge_in_bar = true; } // Clock tools engine API (ui-core/ClockEngine.h) — ClockToolsScreen drives // these; the engine runs from tickCore() so it fires regardless of the screen. void onAlarmChanged(); diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index 1757715a..fd479f57 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -549,6 +549,15 @@ MINI_ICON(ICON_CHART, 9, // ascending bars — Home › Status packRow("..#.#.#.#"), packRow("#.#.#.#.#"), packRow("#########")); +MINI_ICON(ICON_MAIL, 11, // envelope -- the compact new-message alert + packRow("###########"), + packRow("##.......##"), + packRow("#.#.....#.#"), + packRow("#..#...#..#"), + packRow("#...#.#...#"), + packRow("#....#....#"), + packRow("#.........#"), + packRow("###########")); #elif MINI_ICONS_LARGE MINI_ICON(ICON_MUTE, 8, // speaker + cross (sound off) packRow("...#...."), @@ -631,6 +640,14 @@ MINI_ICON(ICON_CHART, 7, // ascending bars — Home › Status packRow("..#.#.#"), packRow("#.#.#.#"), packRow("#######")); +MINI_ICON(ICON_MAIL, 9, // envelope -- the compact new-message alert + packRow("#########"), + packRow("##.....##"), + packRow("#.#...#.#"), + packRow("#..#.#..#"), + packRow("#...#...#"), + packRow("#.......#"), + packRow("#########")); #else MINI_ICON(ICON_MUTE, 6, // speaker + cross (sound off) packRow("..#..."), @@ -706,8 +723,31 @@ MINI_ICON(ICON_CHART, 5, // ascending bars — Home › Status packRow("#.#.#"), packRow("#.#.#"), packRow("#####")); +MINI_ICON(ICON_MAIL, 7, // envelope -- the compact new-message alert + packRow("#######"), + packRow("##...##"), + packRow("#.#.#.#"), + packRow("#..#..#"), + packRow("#######")); #endif +// Settings > Message alert Compact: an envelope and the count beside it in +// mini digits, centred in a box box_h tall from y -- the status icons' row, +// or a header's line. +inline int mailCountWidth(DisplayDriver& d, int n) { + const int s = miniIconScale(d); + return ICON_MAIL.w * s + 2 * s + miniIconNumberWidth(d, n); +} +inline void drawMailCount(DisplayDriver& d, int x, int y, int box_h, int n) { + const int s = miniIconScale(d); + miniIconDrawTop(d, x, y + (box_h - ICON_MAIL.h * s) / 2, ICON_MAIL); + x += ICON_MAIL.w * s + 2 * s; + const int dy = y + (box_h - ICON_DIGIT_0.h * s) / 2; + if (n > 99) n = 99; + if (n >= 10) { miniIconDrawTop(d, x, dy, *MINI_ICON_DIGITS[n / 10]); x += ICON_DIGIT_0.w * s + s; } + miniIconDrawTop(d, x, dy, *MINI_ICON_DIGITS[n % 10]); +} + // Home-carousel page glyphs — a uniform 5x5 set, deliberately smaller than the // menu/status icons above, used in place of the page-indicator dots. One per // HomePage; see UITask HomeScreen::pageIcon().