feat(ui): full e-ink refresh on screen change + prune gone favourites

Two UI quick wins from the 2026-07-05 review:

- E-ink: force a full (non-partial) refresh on the first frame of a new
  screen. Inter-screen ghosting was the most visible cheap win — the
  N-partials interval alone doesn't catch navigation. New
  DisplayDriver::forceFullRefresh() (no-op on OLED), set in setCurrScreen()
  and consumed by GxEPDDisplay::endFrame().

- Favourites: clear a stale "(gone)" tile at render time so it reverts to an
  empty "+" slot. Happens when prefs outlive the contact list (e.g. a wiped
  /contacts3); onContactRemoved only catches a live delete. Pruned slots are
  persisted once per pass (self-healing — an emptied slot can't re-fire).

Builds green: WioTrackerL1_companion_solo_dual (OLED),
WioTrackerL1Eink_companion_solo_dual (e-ink).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-07-05 22:40:14 +02:00
parent 4ae3d22654
commit 638eea7bb0
4 changed files with 31 additions and 3 deletions

View File

@@ -1192,6 +1192,7 @@ public:
if (_fav_sel >= NodePrefs::FAVOURITES_COUNT) _fav_sel = 0; if (_fav_sel >= NodePrefs::FAVOURITES_COUNT) _fav_sel = 0;
bool fav_changed = false; // a stale (gone) slot was pruned this pass → persist once after the loop
for (uint8_t i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) { for (uint8_t i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
int row = i / cols; int row = i / cols;
int col = i % cols; int col = i % cols;
@@ -1218,9 +1219,21 @@ public:
ci = c; found = true; break; ci = c; found = true; break;
} }
} }
if (!found) {
// Pinned contact is gone — prefs outlived the contact list (e.g. a
// wiped /contacts3; onContactRemoved only catches a live delete).
// Clear the stale slot at render time so it reverts to an empty "+"
// tile instead of showing "(gone)". Persisted once after the loop.
if (_node_prefs)
memset(_node_prefs->favourite_contacts[i], 0, NodePrefs::FAVOURITE_PREFIX_LEN);
fav_changed = true;
int plus_y = cy + (cell_h - line_h) / 2;
display.drawTextCentered(cx + cell_w / 2, plus_y, "+");
if (sel) display.setColor(DisplayDriver::LIGHT);
continue;
}
char name[24]; char name[24];
if (found) display.translateUTF8ToBlocks(name, ci.name, sizeof(name)); display.translateUTF8ToBlocks(name, ci.name, sizeof(name));
else strncpy(name, "(gone)", sizeof(name) - 1), name[sizeof(name) - 1] = '\0';
// Reserve space for the unread badge so the name's ellipsis lands // Reserve space for the unread badge so the name's ellipsis lands
// before it instead of underneath. Badge and name share one baseline. // before it instead of underneath. Badge and name share one baseline.
@@ -1247,6 +1260,10 @@ public:
} }
if (sel) display.setColor(DisplayDriver::LIGHT); if (sel) display.setColor(DisplayDriver::LIGHT);
} }
// Persist any pruned slots once, outside the loop — a render pass can clear
// several gone tiles but only one flash write is needed. Self-healing: once
// cleared, the slot is empty next frame so this can't re-fire per frame.
if (fav_changed) the_mesh.savePrefs();
if (_pin_menu.active) _pin_menu.render(display); if (_pin_menu.active) _pin_menu.render(display);
} else if (_page == HomePage::SHUTDOWN) { } else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
@@ -1910,6 +1927,10 @@ void UITask::setCurrScreen(UIScreen* c) {
// that mistake is an inert no-op instead of a null deref in render()/poll(). // that mistake is an inert no-op instead of a null deref in render()/poll().
if (!c) return; if (!c) return;
curr = c; curr = c;
// E-ink: force a full refresh on the first frame of the new screen so leftover
// ghosting from the previous screen is cleared (the N-partials interval alone
// doesn't catch navigation). No-op on OLED.
if (_display) _display->forceFullRefresh();
c->onShow(); // central per-visit reset hook (see UIScreen::onShow) c->onShow(); // central per-visit reset hook (see UIScreen::onShow)
_next_refresh = 100; _next_refresh = 100;
} }

View File

@@ -272,6 +272,7 @@ public:
virtual void setBrightness(uint8_t level) { } // level 0-4 (min to max), no-op default virtual void setBrightness(uint8_t level) { } // level 0-4 (min to max), no-op default
virtual void setDisplayRotation(uint8_t rot) { } // 0-3, no-op for fixed-orientation displays virtual void setDisplayRotation(uint8_t rot) { } // 0-3, no-op for fixed-orientation displays
virtual void setFullRefreshInterval(uint8_t n) { } // e-ink: do full refresh every n partial refreshes (0=never) virtual void setFullRefreshInterval(uint8_t n) { } // e-ink: do full refresh every n partial refreshes (0=never)
virtual void forceFullRefresh() { } // e-ink: make the next frame a full (non-partial) refresh — clears ghosting on screen change; no-op on OLED
virtual void endFrame() = 0; virtual void endFrame() = 0;
#ifdef ENABLE_SCREENSHOT #ifdef ENABLE_SCREENSHOT

View File

@@ -275,10 +275,14 @@ void GxEPDDisplay::endFrame() {
uint32_t crc = display_crc.finalize(); uint32_t crc = display_crc.finalize();
if (crc != last_display_crc_value) { if (crc != last_display_crc_value) {
bool partial = true; bool partial = true;
if (_full_refresh_interval > 0 && ++_partial_count >= _full_refresh_interval) { // A forced full refresh (e.g. screen change) clears inter-screen ghosting.
// Short-circuit so _partial_count isn't bumped on the forced pass; the block
// resets it either way, matching the interval-driven full refresh.
if (_force_full || (_full_refresh_interval > 0 && ++_partial_count >= _full_refresh_interval)) {
partial = false; partial = false;
_partial_count = 0; _partial_count = 0;
} }
_force_full = false;
display.display(partial); display.display(partial);
last_display_crc_value = crc; last_display_crc_value = crc;
} }

View File

@@ -64,6 +64,7 @@ class GxEPDDisplay : public DisplayDriver {
int _text_sz = 1; int _text_sz = 1;
uint8_t _full_refresh_interval = 0; uint8_t _full_refresh_interval = 0;
uint8_t _partial_count = 0; uint8_t _partial_count = 0;
bool _force_full = false; // set by forceFullRefresh(); consumed by the next endFrame()
int16_t drawLemonChar(int16_t x, int16_t y, uint32_t cp, int sc); int16_t drawLemonChar(int16_t x, int16_t y, uint32_t cp, int sc);
uint8_t lemonXAdvance(uint32_t cp, int sc); uint8_t lemonXAdvance(uint32_t cp, int sc);
@@ -144,5 +145,6 @@ public:
} }
void setDisplayRotation(uint8_t rot) override; void setDisplayRotation(uint8_t rot) override;
void setFullRefreshInterval(uint8_t n) override { _full_refresh_interval = n; _partial_count = 0; } void setFullRefreshInterval(uint8_t n) override { _full_refresh_interval = n; _partial_count = 0; }
void forceFullRefresh() override { _force_full = true; }
void endFrame() override; void endFrame() override;
}; };