diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index f55a7598..a5a2382f 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -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. diff --git a/examples/companion_radio/ui-core/DmUnreadTable.h b/examples/companion_radio/ui-core/DmUnreadTable.h new file mode 100644 index 00000000..74e62f76 --- /dev/null +++ b/examples/companion_radio/ui-core/DmUnreadTable.h @@ -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]; +}; diff --git a/examples/companion_radio/ui-new/MessageHistory.h b/examples/companion_radio/ui-core/MessageHistory.h similarity index 99% rename from examples/companion_radio/ui-new/MessageHistory.h rename to examples/companion_radio/ui-core/MessageHistory.h index 73984264..109ca120 100644 --- a/examples/companion_radio/ui-new/MessageHistory.h +++ b/examples/companion_radio/ui-core/MessageHistory.h @@ -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. diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h new file mode 100644 index 00000000..1471e5ff --- /dev/null +++ b/examples/companion_radio/ui-core/UiCore.h @@ -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(); + } +}; diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 82bd29e7..c25afaa3 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -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); } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 64a69473..41c3bac6 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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 diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 52f5b020..dcd1d636 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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; }