refactor(ui-core): add UI Core skeleton; move MessageHistory + DM unread into it

New examples/companion_radio/ui-core/ (header-only for now, reached from
ui-new/UITask.cpp by relative include, so no platformio.ini changes):
UiCore facade, MessageHistory (moved verbatim), DmUnreadTable (extracted
from UITask). UITask heap-allocates the Core in begin(); MessagesScreen
binds to core().history. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-24 23:00:46 +02:00
co-authored by Claude Opus 5.5
parent 2a8d4598d4
commit 3b7aa28ff9
7 changed files with 181 additions and 102 deletions
+3 -3
View File
@@ -28,11 +28,11 @@ Non-goals: changing the mesh protocol, `MyMesh`, persistence formats
interleaved. Classified:
**Models — already UI-free, move as-is**
- `MessageHistory.h` — channel (48) / DM (32) rings, unread counters + overflow flags.
- `MessageHistory.h` — channel (48) / DM (32) rings, unread counters + overflow flags. ✅ moved to `ui-core/`.
- `Trail.h` / `TrailStore`, waypoints (`WaypointsView` storage half), `ScopeList.h`.
**Engines — logic living inside `UITask.cpp` / `UITask.h`**
- Unread tracking — DM unread table (`_dm_unread_table`, `newMsg`, `addDMMsg`, `reconcileDMUnread`), room unread.
- Unread tracking — DM unread table ✅ (`ui-core/DmUnreadTable.h`), room unread (still in `UITask`).
- Notifications — `showAlert`, `notify`, `SoundNotifier`, LED (`userLedHandler`), wake-on-message (`checkDisplayOn`, auto-off).
- Live share — session timer, movement/heartbeat gate, `sendLocationShare`, scope guard, `onSharedLocation`/live-track expiry.
- Locator — geofence state machine, proximity beeper (`evaluateLocator`, `fireLocator`, targets).
@@ -150,7 +150,7 @@ Each step keeps `WioTrackerL1_companion_solo_dual` behaviour identical and is
checked in the sim plus on L1 hardware before the next one.
0. **Listener boundary** ✅ (upstream `MyMesh::Listener` ported; `MyMesh` has no UI calls left).
1. **Skeleton.** `examples/companion_radio/ui-core/`, compiled through a single unity `.cpp` inside each frontend directory so the 66 variant `platformio.ini` files that already build `ui-new` need no changes; add it to every solo env's `build_src_filter` / include path. Move `MessageHistory` + DM unread tracking into it; `ui-new` uses them through the Core.
1. **Skeleton** ✅. `examples/companion_radio/ui-core/` with `UiCore.h` (facade), `MessageHistory.h`, `DmUnreadTable.h`. Header-only for now, reached from `ui-new/UITask.cpp` by relative include, so none of the 66 variant `platformio.ini` files that build `ui-new` change. `UITask` heap-allocates one `UiCore` in `begin()` (before the screens, as `MessagesScreen` used to own the history on the heap); `MessagesScreen` binds to `core().history` by reference. When the Core grows real `.cpp` files, compile them through a unity `.cpp` inside each frontend directory.
2. **Engines, one per commit.** Clock tools → ping → course-over-ground → live share → locator → trail → notifications. `UITask` shrinks to screen management + drawing.
3. **Flip the interface.** `UiCore` implements `AbstractUITask`; `ui-new`'s `UITask` becomes a frontend fed by events.
4. **Settings schema.** Convert `SettingsScreen` section by section.
@@ -0,0 +1,110 @@
#pragma once
// Per-contact DM unread counters. Kept apart from the DM ring in MessageHistory
// (which has no per-contact index), keyed by the same 4-byte pub_key prefix,
// and always read back clamped to what the ring still holds for that contact --
// the same self-healing shape as MessageHistory::chUnread() for channels.
//
// Header-only UI Core model (see docs/development/ui-core.md); no drawing, no
// screen state.
class DmUnreadTable {
public:
static const int SIZE = 16;
DmUnreadTable() { clearAll(); }
// An incoming DM from pub_key is about to be stored (called before the ring
// insert). A sender that doesn't fit in the fixed table is simply not counted.
void onIncoming(const uint8_t* pub_key) {
int slot = -1, empty_slot = -1;
for (int i = 0; i < SIZE; i++) {
if (_t[i].count > 0 && memcmp(_t[i].prefix, pub_key, 4) == 0) { slot = i; break; }
if (empty_slot < 0 && _t[i].count == 0) empty_slot = i;
}
if (slot >= 0) {
if (_t[slot].count < 99) _t[slot].count++;
} else if (empty_slot >= 0) {
memcpy(_t[empty_slot].prefix, pub_key, 4);
_t[empty_slot].count = 1;
_t[empty_slot].overflow = false; // fresh contact -- don't inherit a stale flag from whoever held this slot before
}
// Eviction/overflow is checked in afterInsert(), after the ring insert.
}
// The DM ring (unlike the channel ring) doesn't proactively decrement the
// unread counters as it evicts old entries, so catch it here, right after
// the insert: a raw count claiming more unread than the ring still holds
// for that contact means one of their unread entries was just evicted. Any
// contact can lose one -- not just this sender -- so check every slot.
// Clamp back to the honest value and flag it (mirrors MessageHistory's
// channel-side fix). Must run after the insert, not in onIncoming() (called
// before it), or the new message itself reads as evicted.
void afterInsert(const MessageHistory& h) {
for (int i = 0; i < SIZE; i++) {
if (_t[i].count == 0) continue;
int held = h.dmHistCountForContact(_t[i].prefix);
if (_t[i].count > held) {
_t[i].count = (uint8_t)held;
_t[i].overflow = held > 0; // held == 0 frees the slot -- nothing left to flag
}
}
}
int total(const MessageHistory& h) const {
int total = 0;
for (int i = 0; i < SIZE; i++) {
if (_t[i].count == 0) continue;
int held = h.dmHistCountForContact(_t[i].prefix);
total += (_t[i].count < held) ? _t[i].count : held;
}
return total;
}
uint8_t get(const MessageHistory& h, const uint8_t* pub_key) const {
for (int i = 0; i < SIZE; i++) {
if (_t[i].count > 0 && memcmp(_t[i].prefix, pub_key, 4) == 0) {
int held = h.dmHistCountForContact(pub_key);
return _t[i].count < held ? _t[i].count : (uint8_t)held;
}
}
return 0;
}
// True once an unread entry for this contact was evicted off the DM ring
// before ever being seen -- count is honest but understates the real total.
bool overflow(const uint8_t* pub_key) const {
for (int i = 0; i < SIZE; i++)
if (_t[i].count > 0 && memcmp(_t[i].prefix, pub_key, 4) == 0)
return _t[i].overflow;
return false;
}
bool anyOverflow() const {
for (int i = 0; i < SIZE; i++)
if (_t[i].count > 0 && _t[i].overflow) return true;
return false;
}
void clear(const uint8_t* pub_key) {
for (int i = 0; i < SIZE; i++)
if (_t[i].count > 0 && memcmp(_t[i].prefix, pub_key, 4) == 0)
{ _t[i].count = 0; _t[i].overflow = false; return; }
}
void clearAll() { memset(_t, 0, sizeof(_t)); }
// Frees any slot whose ring occupancy has dropped to zero (evicted or
// deduped-away messages) so a genuinely new sender isn't starved once the
// fixed table fills with stale entries.
void reconcile(const MessageHistory& h) {
for (int i = 0; i < SIZE; i++) {
if (_t[i].count == 0) continue;
if (h.dmHistCountForContact(_t[i].prefix) == 0) {
_t[i].count = 0; // ring no longer holds anything for this sender -- free the slot
_t[i].overflow = false;
}
}
}
private:
struct Entry { uint8_t prefix[4]; uint8_t count; bool overflow; };
Entry _t[SIZE];
};
@@ -6,7 +6,8 @@
// scroll, the unread "viewing session" bookkeeping, the room-login table, and
// all rendering, and reaches entries through the accessors below.
//
// Single-TU fragment: included by UITask.cpp before MessagesScreen.h. AckState,
// UI Core model, owned by UiCore (UiCore.h). Single-TU fragment: reached from
// the frontend's UITask.cpp via UiCore.h, before MessagesScreen.h. AckState,
// MSG_TEXT_BUF and the two entry structs are file-scope (not nested) so the
// phase machine in MessagesScreen keeps referring to them unqualified.
+36
View File
@@ -0,0 +1,36 @@
#pragma once
// UI Core: hardware-independent UI state and logic shared by every frontend
// (ui-new today, ui-lvgl later). See docs/development/ui-core.md.
//
// For now the Core is header-only and compiled as part of the frontend's single
// translation unit (ui-new/UITask.cpp includes this file), so no platformio.ini
// needs a new source filter or include path. It relies on the same globals the
// frontend does (the_mesh, rtc_clock).
#include "MessageHistory.h"
#include "DmUnreadTable.h"
class UiCore {
public:
// ── Models ────────────────────────────────────────────────────────────────
MessageHistory history; // channel + DM rings, delivery state, channel unread
DmUnreadTable dm_unread; // per-contact DM unread counters
// ── DM unread (clamped to what the DM ring still holds) ──────────────────
void noteIncomingDM(const uint8_t* pub_key) { dm_unread.onIncoming(pub_key); }
void afterDMInsert() { dm_unread.afterInsert(history); }
int dmUnreadTotal() const { return dm_unread.total(history); }
uint8_t dmUnread(const uint8_t* pub_key) const { return dm_unread.get(history, pub_key); }
bool dmUnreadOverflow(const uint8_t* pub_key) const { return dm_unread.overflow(pub_key); }
bool anyDMUnreadOverflow() const { return dm_unread.anyOverflow(); }
void clearDMUnread(const uint8_t* pub_key) { dm_unread.clear(pub_key); }
void clearAllDMUnread() { dm_unread.clearAll(); }
void reconcileDMUnread() { dm_unread.reconcile(history); }
// All messages synced to the app: nothing is unread any more (room unread
// still lives in the frontend).
void clearAllUnread() {
dm_unread.clearAll();
history.clearAllChannelUnread();
}
};
@@ -136,12 +136,13 @@ class MessagesScreen : public UIScreen {
bool _pick_bot_room = false;
// The message-history rings (channel + DM), their per-entry delivery state and
// per-channel unread counters live in this store (see MessageHistory.h). The
// per-channel unread counters live in the UI Core's store (ui-core/
// MessageHistory.h); this screen binds to it by reference. The
// phase machine below keeps only the view state — selection, scroll, the
// fullscreen readers — and reaches entries through _history's accessors. The
// shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are file-
// scope, so they're still referred to unqualified throughout this screen.
MessageHistory _history;
MessageHistory& _history;
// DM_HIST view state (the ring itself is in _history).
int _dm_hist_sel, _dm_hist_scroll;
@@ -942,10 +943,11 @@ public:
_msg_sel(0), _msg_scroll(0), _active_msg_count(0),
_hist_sel(0), _hist_scroll(0),
_unread_at_entry(0), _viewing_max_seen(0),
_history(task->core().history),
_dm_hist_sel(-1), _dm_hist_scroll(0),
_ctx_dirty(false), _pin_picker_active(false), _direct_entry(false), _reply_mode(false),
_ch_view(task) {
// The history rings + per-channel unread counters init in MessageHistory.
// The history rings + per-channel unread counters live in UiCore.
}
// Forwarded to UITask's GPS duty-cycle hold — true while showing the
@@ -1111,7 +1113,7 @@ public:
bool anyChannelUnreadOverflow() const { return _history.anyChannelUnreadOverflow(); }
// How many DM ring entries this contact/room currently holds -- lets UITask
// clamp its separate _dm_unread_table counters to what the shared 32-slot DM
// clamp the Core's separate DM unread counters to what the shared 32-slot DM
// ring actually still has, the same self-healing shape as the channel fix.
int dmHistCountForContact(const uint8_t* pub_key) const { return _history.dmHistCountForContact(pub_key); }
+13 -63
View File
@@ -160,7 +160,7 @@ static const int QUICK_MSGS_MAX = 10;
#include "FullscreenMsgView.h"
#include "SensorPlaceholders.h"
#include "SettingsScreen.h"
#include "MessageHistory.h" // RAM history rings (DM + channel) used by MessagesScreen
#include "../ui-core/UiCore.h" // shared UI Core: history rings + unread models (MessagesScreen views them)
#include "MessagesScreen.h"
// ── Custom screens (separate files to ease upstream merges) ───────────────────
@@ -1654,6 +1654,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
_ping_snr_back_x4 = 0;
_ping_rtt_ms = 0;
_core = new UiCore(); // before any screen -- MessagesScreen binds to its history
splash = new SplashScreen(this);
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
syncLockToHome(); // booted locked (e.g. cover closed) → home starts on the LOCK page
@@ -2010,53 +2011,15 @@ void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, u
uint32_t ack_tag, uint32_t ack_deadline_ms, uint8_t resends,
const uint8_t* path, uint8_t path_len) {
((MessagesScreen*)messages_screen)->addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends, path, path_len);
// The DM ring (unlike the channel ring) doesn't proactively decrement the
// unread counters as it evicts old entries, so catch it here, right after
// the insert: a raw count claiming more unread than the ring still holds
// for that contact means one of their unread entries was just evicted. Any
// contact can lose one -- not just this sender -- so check every slot.
// Clamp back to the honest value and flag it (mirrors MessageHistory's
// channel-side fix). Must run after the insert, not in newMsg() (called
// before it), or the new message itself reads as evicted.
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
if (_dm_unread_table[i].count == 0) continue;
int held = ((MessagesScreen*)messages_screen)->dmHistCountForContact(_dm_unread_table[i].prefix);
if (_dm_unread_table[i].count > held) {
_dm_unread_table[i].count = (uint8_t)held;
_dm_unread_table[i].overflow = held > 0; // held == 0 frees the slot -- nothing left to flag
}
}
_core->afterDMInsert();
}
int UITask::getDMUnreadTotal() const {
int total = 0;
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
if (_dm_unread_table[i].count == 0) continue;
int held = ((MessagesScreen*)messages_screen)->dmHistCountForContact(_dm_unread_table[i].prefix);
total += (_dm_unread_table[i].count < held) ? _dm_unread_table[i].count : held;
}
return total;
}
uint8_t UITask::getDMUnread(const uint8_t* pub_key) const {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) {
int held = ((MessagesScreen*)messages_screen)->dmHistCountForContact(pub_key);
return _dm_unread_table[i].count < held ? _dm_unread_table[i].count : (uint8_t)held;
}
}
return 0;
}
void UITask::reconcileDMUnread() {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
if (_dm_unread_table[i].count == 0) continue;
if (((MessagesScreen*)messages_screen)->dmHistCountForContact(_dm_unread_table[i].prefix) == 0) {
_dm_unread_table[i].count = 0; // ring no longer holds anything for this sender -- free the slot
_dm_unread_table[i].overflow = false;
}
}
}
int UITask::getDMUnreadTotal() const { return _core->dmUnreadTotal(); }
uint8_t UITask::getDMUnread(const uint8_t* pub_key) const { return _core->dmUnread(pub_key); }
bool UITask::getDMUnreadOverflow(const uint8_t* pub_key) const { return _core->dmUnreadOverflow(pub_key); }
bool UITask::getAnyDMUnreadOverflow() const { return _core->anyDMUnreadOverflow(); }
void UITask::clearDMUnread(const uint8_t* pub_key) { _core->clearDMUnread(pub_key); }
void UITask::clearAllDMUnread() { _core->clearAllDMUnread(); }
void UITask::showAlert(const char* text, int duration_millis) {
snprintf(_alert, sizeof(_alert), "%s", text);
@@ -2108,8 +2071,7 @@ void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
_room_unread = 0;
memset(_dm_unread_table, 0, sizeof(_dm_unread_table));
((MessagesScreen*)messages_screen)->clearAllChannelUnread();
_core->clearAllUnread();
}
}
@@ -2119,19 +2081,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
if (contact_type == ADV_TYPE_CHAT && pub_key != nullptr) {
memcpy(_last_notif_dm_prefix, pub_key, 4);
_last_notif_dm_valid = true;
int slot = -1, empty_slot = -1;
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) { slot = i; break; }
if (empty_slot < 0 && _dm_unread_table[i].count == 0) empty_slot = i;
}
if (slot >= 0) {
if (_dm_unread_table[slot].count < 99) _dm_unread_table[slot].count++;
} else if (empty_slot >= 0) {
memcpy(_dm_unread_table[empty_slot].prefix, pub_key, 4);
_dm_unread_table[empty_slot].count = 1;
_dm_unread_table[empty_slot].overflow = false; // fresh contact -- don't inherit a stale flag from whoever held this slot before
}
// Eviction/overflow is checked in addDMMsg(), after the ring insert.
_core->noteIncomingDM(pub_key); // eviction/overflow is checked in addDMMsg(), after the ring insert
}
char alert_buf[80];
@@ -2646,7 +2596,7 @@ void UITask::loop() {
// Background delivery: resend pending on-device DMs whose ACK timed out, and
// finalise the ✗ marker — runs regardless of which screen is active.
((MessagesScreen*)messages_screen)->tickDmResends();
reconcileDMUnread();
_core->reconcileDMUnread();
#if UI_HAS_JOYSTICK
uint8_t joy_rot = _node_prefs ? _node_prefs->joystick_rotation : JOYSTICK_ROTATION;
int ev = user_btn.check();
@@ -3273,7 +3223,7 @@ void UITask::clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6) {
// cleared here, so a removed contact can't leave a dangling reference. If you
// add such a field, add its cleanup below (and mark the field in NodePrefs.h).
// Currently covered: favourite_contacts, locator_key, loc_share_dm_prefix,
// dm_notif[], dm_melody[]. Also clears _dm_unread_table (RAM-only, not a
// dm_notif[], dm_melody[]. Also clears the DM unread table (RAM-only, not a
// NodePrefs field, so no savePrefs() needed for it) -- same 4-byte-prefix
// shape and same 16-slot starvation risk as dm_notif/dm_melody above. Called
// for both explicit removal and silent auto-eviction (see MyMesh
+11 -31
View File
@@ -42,6 +42,8 @@
#include "../LiveTrack.h"
#include "KeyboardWidget.h"
class UiCore;
class UITask : public AbstractUITask {
DisplayDriver* _display;
SensorManager* _sensors;
@@ -71,12 +73,9 @@ class UITask : public AbstractUITask {
int _last_notif_ch_idx;
uint8_t _last_notif_dm_prefix[4];
bool _last_notif_dm_valid;
// overflow: an unread entry for this contact was evicted off the DM ring
// before ever being seen (see newMsg()) -- count is honest but understates
// the real total. Cleared with the slot (memset) or on a read back to 0.
struct DMUnreadEntry { uint8_t prefix[4]; uint8_t count; bool overflow; };
static const int DM_UNREAD_TABLE_SIZE = 16;
DMUnreadEntry _dm_unread_table[DM_UNREAD_TABLE_SIZE];
// Shared UI Core (../ui-core): message history + unread models. Heap-allocated
// in begin(), before any screen, like the screens themselves.
UiCore* _core = nullptr;
unsigned long ui_started_at, next_batt_chck;
uint16_t _batt_mv; // EMA-filtered battery voltage
unsigned long next_backlight_btn_check = 0;
@@ -307,7 +306,6 @@ public:
_last_notif_ch_idx = -1;
_last_notif_dm_valid = false;
memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix));
memset(_dm_unread_table, 0, sizeof(_dm_unread_table));
curr = NULL;
}
void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
@@ -457,35 +455,17 @@ public:
bool getAnyChannelUnreadOverflow() const;
int getRoomUnreadCount() const { return _room_unread; }
void clearRoomUnread() { _room_unread = 0; }
// Clamped to the DM ring's actual occupancy for this contact -- defined in
// UITask.cpp (needs MessagesScreen to be a complete type). Same self-healing
// shape as MessageHistory::chUnread() for channels.
// Clamped to the DM ring's actual occupancy for this contact (UiCore).
uint8_t getDMUnread(const uint8_t* pub_key) const;
bool getDMUnreadOverflow(const uint8_t* pub_key) const {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++)
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0)
return _dm_unread_table[i].overflow;
return false;
}
bool getAnyDMUnreadOverflow() const {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++)
if (_dm_unread_table[i].count > 0 && _dm_unread_table[i].overflow) return true;
return false;
}
bool getDMUnreadOverflow(const uint8_t* pub_key) const;
bool getAnyDMUnreadOverflow() const;
// Aggregate for the clock/lock dashboard's single "Msgs" field -- true if
// ANY channel or DM contact has permanently lost an unread entry to its
// ring cap (rooms have no local ring to overflow the same way).
bool getAnyUnreadOverflow() const;
void clearDMUnread(const uint8_t* pub_key) {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++)
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0)
{ _dm_unread_table[i].count = 0; _dm_unread_table[i].overflow = false; return; }
}
void clearAllDMUnread() { memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); }
// Frees any table slot whose ring occupancy has dropped to zero (evicted or
// deduped-away messages) so a genuinely new sender isn't starved once the
// fixed 16-slot table fills with stale entries. Called once per loop().
void reconcileDMUnread();
void clearDMUnread(const uint8_t* pub_key);
void clearAllDMUnread();
UiCore& core() { return *_core; }
bool hasDisplay() const { return _display != NULL; }
DisplayDriver* getDisplay() const { return _display; }