fix(eink): drive the whole panel on every update, not just the changes

The screen faded as the device was used: text went grey a few updates
after it was drawn, while whatever had just changed stayed crisp. Only a
full refresh brought it back, and the next few updates ate it again.

A partial update is differential -- the controller drives only the pixels
that differ between its "current" (0x24) and "previous" (0x26) RAM and
leaves the rest to hold their own charge, which this panel doesn't do
well. Priming the previous-image RAM with the inverse of the incoming
frame makes every pixel a difference, so all of them get driven to their
target, in both directions.

The inverse matters: priming with flat white makes only white->black a
difference, so ink is re-driven but never erased and every screen ever
shown accumulates as a ghost. Confirmed on hardware, both ways round.

Costs one extra full-screen RAM write (a few ms of SPI). The refresh
itself is unchanged -- the waveform clocks the whole panel regardless of
how many pixels it drives -- so nothing got slower. Clearing ghosts is
still the periodic full refresh's job (Settings > Full rfsh), which can
now stay off.

The helper needs GxEPD2_BW's private _buffer/_page_height, so it goes in
the patched copy of the header, which every e-ink build now includes
unconditionally rather than only screenshot-enabled ones. All e-ink
variants pin GxEPD2 1.6.2, the version that copy tracks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-07-28 15:02:28 +02:00
parent b36cc7730b
commit de16dbe32a
4 changed files with 34 additions and 8 deletions

View File

@@ -235,6 +235,15 @@ class GxEPD2_BW : public GxEPD2_GFX_BASE_CLASS
const uint8_t* getBuffer() { return _buffer; }
uint16_t getBufferSize() { return sizeof(_buffer); }
#endif
// Prime the controller's "previous image" RAM with the inverse of the frame
// display(true) is about to send, so that update drives every pixel instead
// of only the changed ones. Rationale in GxEPDDisplay::endFrame(); it lives
// here because it needs _buffer and _page_height. Same patched-copy note as
// above.
void writeInverseForRedrive()
{
epd2.writeImageAgain(_buffer, 0, 0, GxEPD2_Type::WIDTH, _page_height, true);
}
#if ENABLE_GxEPD2_GFX
GxEPD2_BW(GxEPD2_Type epd2_instance) : GxEPD2_GFX_BASE_CLASS(epd2, GxEPD2_Type::WIDTH_VISIBLE, GxEPD2_Type::HEIGHT), epd2(epd2_instance)
#else

View File

@@ -22,6 +22,7 @@
- **Typing on CardKB right after cycling a T9 letter, then tapping that same T9 cell again quickly, could overwrite an unrelated character.** None of CardKB's direct actions — typing, moving the cursor with the arrows, or opening the placeholder picker with Tab — were invalidating the pending T9 cycle the way every on-screen keypress already does, so the next tap "continued" the cycle against a moved cursor.
- **The keyboard's text preview could split a Cyrillic, Greek or accented character in half at the end of a line**, drawing garbage on both sides of the break, and fitted only half as much text per line as it had room for. Line breaks in the preview were counted in bytes rather than characters — every other part of the keyboard already counted characters.
- **Caps-lock and one-shot Shift looked identical on the keyboard's ⇧ key**, so there was no way to tell whether the next letter alone or every following letter would be capitalised. Caps-lock is now marked with an underline on the key.
- **E-ink: the screen faded as you used it** — text drawn a few updates earlier went grey while whatever had just changed stayed crisp, and only a full refresh brought it back (until the next few updates ate it again). A partial update is differential: the panel drives only the pixels that differ from the previous frame and leaves the rest to hold their own charge, which this panel doesn't do well. Every update now drives the whole image, at the cost of a few milliseconds of SPI — the refresh itself takes exactly as long as before, so nothing got slower. Cutting power mid-image also leaves much less residue on the panel now. **Full rfsh** is still there for clearing ghosts and can stay off.
- **A message containing a line break drew two words on top of each other.** Newlines the sender typed were treated as ordinary characters — measured as a blank 6px cell, then handed to the display, which moved back to the left edge one row down mid-line and drew the rest of it over the following line. Line breaks now end the line properly (blank lines included) in the fullscreen message view and the history list, and fold into a space in the one-line message previews.
---

View File

@@ -279,6 +279,23 @@ void GxEPDDisplay::endFrame() {
partial = false;
_partial_count = 0;
}
// Drive every pixel, not just the ones that changed since the last frame.
// A partial update is differential — it drives only what differs from the
// controller's "previous image" RAM and leaves the rest to hold its own
// charge, which this panel doesn't do well: text went grey a few updates
// after it was drawn while whatever had just changed stayed crisp. Priming
// that RAM with the inverse of the incoming frame makes every pixel a
// difference, so all of them get driven to their target.
//
// It must be the inverse and not a flat white — white makes only
// white->black a difference, so ink gets re-driven but never erased and
// every screen ever shown accumulates as a ghost.
//
// Costs one extra full-screen RAM write (a few ms of SPI); the refresh
// itself takes the same time either way, as the waveform clocks the whole
// panel regardless of how many pixels it actually drives. Clearing ghosts
// is a separate job and stays with the periodic full refresh above.
if (partial) display.writeInverseForRedrive();
display.display(partial);
last_display_crc_value = crc;
}

View File

@@ -5,15 +5,14 @@
#define ENABLE_GxEPD2_GFX 0
// When ENABLE_SCREENSHOT is active, use the patched copy of GxEPD2_BW.h from
// lib/GxEPD2-patch/src/ that exposes getBuffer()/getBufferSize(). The include
// guard (_GxEPD2_BW_H_) then prevents the installed library version from
// being compiled again. Non-screenshot builds use the installed library as-is.
#ifdef ENABLE_SCREENSHOT
// Always use the patched copy of GxEPD2_BW.h from lib/GxEPD2-patch/src/: it
// adds writeInverseForRedrive() (needed by every e-ink build, see endFrame())
// and, under ENABLE_SCREENSHOT, getBuffer()/getBufferSize(). Both need the
// class's private framebuffer, so they have to live in the header itself. The
// include guard (_GxEPD2_BW_H_) prevents the installed library version from
// being compiled as well; the copy tracks the 1.6.2 pin every e-ink variant's
// lib_deps uses.
#include "../../../lib/GxEPD2-patch/src/GxEPD2_BW.h"
#else
#include <GxEPD2_BW.h>
#endif
#include <GxEPD2_3C.h>
#include <GxEPD2_4C.h>
#include <GxEPD2_7C.h>