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
+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