diff --git a/Devices/m5stack-papers3/source/drivers/papers3_display.cpp b/Devices/m5stack-papers3/source/drivers/papers3_display.cpp index 81ff1b928..e6ed4def5 100644 --- a/Devices/m5stack-papers3/source/drivers/papers3_display.cpp +++ b/Devices/m5stack-papers3/source/drivers/papers3_display.cpp @@ -13,8 +13,6 @@ #include -#include - #include #include @@ -33,14 +31,10 @@ static constexpr float FULL_AREA_QUALITY_THRESHOLD = 0.6f; // Bounds worst-case ghost accumulation during sustained fast-mode interaction (e.g. scrolling), // regardless of idle time. LVGL's PARTIAL-mode draw buffer covers vres/10 rows (see // lvgl-module/source/devices/devices.cpp's buffer_height), so a single full-screen redraw is -// already ~10 tiles - this must clear a full sweep comfortably, or a normal full-screen redraw +// already ~10 tiles. This must clear a full sweep comfortably, or a normal full-screen redraw // gets promoted to slow GC16 partway through. static constexpr uint32_t QUALITY_REFRESH_PARTIAL_COUNT = 20; -// Cleans up ghosting left behind after interaction stops, since nothing else triggers a refresh -// once draw_bitmap() calls stop arriving. Matches the M5Stack official demo's timer. -static constexpr uint32_t QUALITY_REFRESH_IDLE_SECONDS = 10; - // LVGL's PARTIAL render mode flushes one draw_bitmap() call per still-unjoined dirty rect, so // one visual refresh is usually several back-to-back calls, not one; this holds quality mode // across a sibling rect's near-zero gap so they don't end up on inconsistent modes. Must stay @@ -48,10 +42,13 @@ static constexpr uint32_t QUALITY_REFRESH_IDLE_SECONDS = 10; // separate real frames and pins a whole multi-frame interaction to GC16. static constexpr uint32_t QUALITY_HOLD_MS = 50; -// epd_fullclear() (white fill + GC16 draw + 3-cycle black/white flash, see epdiy's -// highlevel.c/render.c) only runs once, at boot (papers3_display_init()), never periodically: -// it wipes the whole panel, and this driver has no way to force LVGL to redraw everything -// afterward - only whatever rect is drawn next gets restored, leaving the rest blank. +// Caps how long a single hold session can keep re-extending itself (see +// commit_quality_mode_decision()). A real multi-rect LVGL redraw finishes well within this; a +// caller whose own triggers (is_full_screen_change/partial_count_exceeded) keep firing faster +// than QUALITY_HOLD_MS apart, such as GraphicsDemo's tight banded draw loop, would otherwise keep +// re-extending the hold indefinitely once one of those triggers happened to line up with an +// active hold window. +static constexpr uint32_t QUALITY_HOLD_SESSION_MAX_MS = 200; // 4x4 ordered (Bayer) dither thresholds, spread evenly across a 0-15 nibble range. static constexpr uint8_t BAYER_4X4[4][4] = { @@ -63,7 +60,7 @@ static constexpr uint8_t BAYER_4X4[4][4] = { // Dithers an 8-bit luminance sample (0x00=black..0xFF=white) down to a 4-bit nibble // (0x0=black..0xF=white, matching EPDiy's MODE_PACKING_2PPB), spreading the rounding error -// spatially instead of truncating every pixel the same way - this is what turns flat/banded +// spatially instead of truncating every pixel the same way. This is what turns flat/banded // output into something that reads as smooth grayscale. static inline uint8_t dither_to_nibble(uint8_t luminance, int32_t x, int32_t y) { // BAYER_4X4 is 0-15; scaled by 17 it spans a full 0-255 quantization step (one increment of @@ -74,11 +71,11 @@ static inline uint8_t dither_to_nibble(uint8_t luminance, int32_t x, int32_t y) return static_cast(level > 15U ? 15U : level); } -// Binary variant for MODE_DU, which only supports pure black/white (see epdiy.h) - dithering +// Binary variant for MODE_DU, which only supports pure black/white (see epdiy.h). Dithering // still applies so a partial-update area doesn't look coarser than the quality pass that // preceded it. static inline uint8_t dither_to_bw_nibble(uint8_t luminance, int32_t x, int32_t y) { - // *16 (not 17) keeps the max threshold at 240, strictly below 255 - otherwise luminance 0xFF + // *16 (not 17) keeps the max threshold at 240, strictly below 255. Otherwise luminance 0xFF // (pure white) would tie the top Bayer cell's threshold and the strict ">" would misclassify // it as black. const uint32_t threshold = BAYER_4X4[y & 3][x & 3] * 16U; @@ -97,18 +94,16 @@ static EpdiyHighlevelState s_hl_state = {}; struct Papers3DisplayInternal { EpdiyHighlevelState hl_state; uint8_t* framebuffer; - // Scratch buffer for the grayscale8->EPDiy(4bpp packed, 2px/byte) conversion in draw_bitmap(). - uint8_t* packed_buffer; bool powered; uint32_t panel_pixel_count; // Fast (MODE_DU) partial updates since the last quality refresh; see // QUALITY_REFRESH_PARTIAL_COUNT. uint32_t partial_count_since_quality; - // get_ticks() at the last quality refresh; see QUALITY_REFRESH_IDLE_SECONDS. - TickType_t last_quality_refresh_tick; // While get_ticks() < this, every draw_bitmap() call uses quality mode regardless of the // other triggers; see QUALITY_HOLD_MS. TickType_t quality_hold_until_tick; + // get_ticks() when the current hold session first armed; see QUALITY_HOLD_SESSION_MAX_MS. + TickType_t quality_hold_session_start_tick; }; static void power_on(Papers3DisplayInternal* internal) { @@ -122,8 +117,8 @@ static void power_on(Papers3DisplayInternal* internal) { static error_t papers3_display_reset(Device* device) { auto* internal = static_cast(device_get_driver_data(device)); - // EPD has no discrete reset pin/sequence the way SPI TFT panels do - epd_init() (in start()) - // already performs the real one-time hardware bring-up. A power-cycle is the closest + // EPD has no discrete reset pin/sequence the way SPI TFT panels do. epd_init() (in start()) + // already performs the real one-time hardware bring-up, so a power-cycle is the closest // equivalent available at runtime. epd_poweroff(); internal->powered = false; @@ -131,24 +126,70 @@ static error_t papers3_display_reset(Device* device) { return ERROR_NONE; } -static error_t papers3_display_init(Device* device) { +/** + * Registered as the init() callback, but the current boot path never calls the public + * display_init() wrapper. start() does this work directly instead. Kept as a harmless no-op + * for forward compatibility. + * \see start + */ +static error_t papers3_display_init(Device*) { + return ERROR_NONE; +} + +/** + * White-fill plus a single GC16 redraw. Wipes content an app drew directly via draw_bitmap(), + * skipping epd_fullclear()'s 3-cycle hardware flash, which stays reserved for the boot-priming + * clear. + * \see start + */ +static error_t papers3_display_clear(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); const auto* config = GET_CONFIG(device); + power_on(internal); + epd_hl_set_all_white(&internal->hl_state); + auto result = epd_hl_update_screen(&internal->hl_state, MODE_GC16, config->temperature_celsius); + if (result == EPD_DRAW_SUCCESS) { + internal->partial_count_since_quality = 0; + internal->quality_hold_until_tick = 0; + } + return result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE; +} + +/** + * A GC16 pass over whatever's already in front_fb, without a preceding white-fill, so it clears + * ghosting without visibly changing on-screen content. MODE_DU alone never fully clears prior + * ghosting even when it draws the correct pixels. + * + * epd_hl_update_area()/epd_hl_update_screen() diff front_fb against back_fb and skip any pixel + * where the two already match, regardless of draw mode (see epd_difference_image_base in epdiy's + * render.c). A ghosted region whose logical content hasn't changed since it was last + * committed to back_fb is exactly such a match, so a plain GC16 pass silently skips physically + * re-driving it. Bitwise-inverting back_fb guarantees a mismatch on every byte (a byte can never + * equal its own complement), forcing the diff to treat the whole panel as dirty; back_fb resyncs + * to front_fb afterward via the diff's own post-draw copy. + */ +static error_t papers3_display_refresh(Device* device) { auto* internal = static_cast(device_get_driver_data(device)); + const auto* config = GET_CONFIG(device); power_on(internal); - // The bootloader/boot-logo splash draws via partial refreshes that never get a real quality - // pass, leaving a faint ghost. Run a full clear now, before LVGL's first flush ever reaches - // draw_bitmap(), so it never has to undo content LVGL already put on screen. - epd_fullclear(&internal->hl_state, config->temperature_celsius); - internal->partial_count_since_quality = 0; - internal->last_quality_refresh_tick = get_ticks(); - internal->quality_hold_until_tick = 0; - return ERROR_NONE; + const size_t fb_size = static_cast(epd_width()) / 2 * epd_height(); + uint8_t* back_fb = internal->hl_state.back_fb; + for (size_t i = 0; i < fb_size; i++) { + back_fb[i] = static_cast(~back_fb[i]); + } + auto result = epd_hl_update_screen(&internal->hl_state, MODE_GC16, config->temperature_celsius); + if (result == EPD_DRAW_SUCCESS) { + internal->partial_count_since_quality = 0; + internal->quality_hold_until_tick = 0; + } + return result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE; } // Decides whether this update should be a full-quality (config->quality_draw_mode) refresh or -// a fast MODE_DU one. Read-only - see commit_quality_mode_decision() for the state this decision -// leads to. -static bool should_use_quality_mode(Papers3DisplayInternal* internal, int32_t width, int32_t height) { +// a fast MODE_DU one. Read-only; see commit_quality_mode_decision() for the state this decision +// leads to. *out_within_hold reports whether the hold trigger specifically fired, since +// commit_quality_mode_decision() treats that case differently from the others. +static bool should_use_quality_mode(Papers3DisplayInternal* internal, int32_t width, int32_t height, bool* out_within_hold) { const TickType_t now = get_ticks(); const uint32_t area = static_cast(width) * static_cast(height); @@ -156,25 +197,38 @@ static bool should_use_quality_mode(Papers3DisplayInternal* internal, int32_t wi static_cast(internal->panel_pixel_count) * FULL_AREA_QUALITY_THRESHOLD ); const bool partial_count_exceeded = internal->partial_count_since_quality >= QUALITY_REFRESH_PARTIAL_COUNT; - // Idle refresh is too problematic to worth the possible gains. So it isn't done on purpose. - // Ghosting is very minimal now anyway (it's still around but way less bad) - const bool idle_exceeded = now - internal->last_quality_refresh_tick >= seconds_to_ticks(QUALITY_REFRESH_IDLE_SECONDS); const bool within_hold = now < internal->quality_hold_until_tick; + *out_within_hold = within_hold; - return is_full_screen_change || partial_count_exceeded || idle_exceeded || within_hold; + return is_full_screen_change || partial_count_exceeded || within_hold; } // Applies should_use_quality_mode()'s decision, but only commits the quality-mode reset once the -// draw actually succeeded - a failed quality refresh must not make a still-ghosting panel look +// draw actually succeeded. A failed quality refresh must not make a still-ghosting panel look // freshly cleaned to every trigger above. A failed fast update still counts toward the partial // count, since it was still MODE_DU content, not a clean slate. -static void commit_quality_mode_decision(Papers3DisplayInternal* internal, bool used_quality, bool draw_succeeded) { +// +// quality_hold_until_tick exists to bridge the near-zero gap between sibling dirty rects of the +// SAME LVGL redraw (see QUALITY_HOLD_MS's comment). A GC16 draw's own duration (400ms+) already +// exceeds QUALITY_HOLD_MS, so unconditionally re-arming a fresh window on every successful quality +// draw let a caller whose own triggers (is_full_screen_change/partial_count_exceeded) keep firing +// faster than QUALITY_HOLD_MS apart, such as GraphicsDemo's tight banded draw loop, perpetuate +// quality mode forever, since each such draw's re-arm was still live by the time the next one +// landed. quality_hold_session_start_tick bounds this: a hold session may keep extending itself +// to bridge consecutive sibling rects, but never past QUALITY_HOLD_SESSION_MAX_MS from when it +// first armed, regardless of how it keeps getting triggered. +static void commit_quality_mode_decision(Papers3DisplayInternal* internal, bool used_quality, bool draw_succeeded, bool was_within_hold) { if (used_quality) { if (draw_succeeded) { - const TickType_t now = get_ticks(); internal->partial_count_since_quality = 0; - internal->last_quality_refresh_tick = now; - internal->quality_hold_until_tick = now + millis_to_ticks(QUALITY_HOLD_MS); + const TickType_t now = get_ticks(); + if (!was_within_hold) { + internal->quality_hold_session_start_tick = now; + } + const TickType_t session_elapsed = now - internal->quality_hold_session_start_tick; + if (session_elapsed < millis_to_ticks(QUALITY_HOLD_SESSION_MAX_MS)) { + internal->quality_hold_until_tick = now + millis_to_ticks(QUALITY_HOLD_MS); + } } } else { internal->partial_count_since_quality++; @@ -182,41 +236,35 @@ static void commit_quality_mode_decision(Papers3DisplayInternal* internal, bool } // Reports GRAYSCALE8 (not MONOCHROME) so LVGL uses partial/tile updates instead of forcing -// full-frame - the bridge hardcodes full-frame for MONOCHROME/I1 regardless of capability flags. -// So draw_bitmap is called once per changed tile, not necessarily the whole panel. +// full-frame, since the bridge hardcodes full-frame for MONOCHROME/I1 regardless of capability +// flags. So draw_bitmap is called once per changed tile, not necessarily the whole panel. static error_t papers3_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) { auto* internal = static_cast(device_get_driver_data(device)); const auto* config = GET_CONFIG(device); const int32_t width = x_end - x_start; const int32_t height = y_end - y_start; - const bool use_quality = should_use_quality_mode(internal, width, height); + bool within_hold = false; + const bool use_quality = should_use_quality_mode(internal, width, height, &within_hold); // color_data is DISPLAY_COLOR_FORMAT_GRAYSCALE8: row-major, 1 byte/pixel luminance // (0x00=black..0xFF=white, matching LVGL's L8). EPDiy wants 4bpp packed (2px/byte, 0x0=black, // 0xF=white); Bayer dithering (full 16-level for the quality pass, binary for MODE_DU) - // spreads the rounding error instead of a flat truncation. + // spreads the rounding error instead of a flat truncation. epd_draw_pixel() applies this + // panel's configured rotation per pixel (see _rotate() in epdiy.c). const auto* src = static_cast(color_data); const size_t src_stride = static_cast(width); - const size_t packed_stride = static_cast(width + 1) / 2; for (int32_t row = 0; row < height; row++) { const uint8_t* src_row = src + static_cast(row) * src_stride; - uint8_t* dst_row = internal->packed_buffer + static_cast(row) * packed_stride; - int32_t col = 0; - for (; col + 2 <= width; col += 2) { - const uint8_t p0 = use_quality - ? dither_to_nibble(src_row[col], x_start + col, y_start + row) - : dither_to_bw_nibble(src_row[col], x_start + col, y_start + row); - const uint8_t p1 = use_quality - ? dither_to_nibble(src_row[col + 1], x_start + col + 1, y_start + row) - : dither_to_bw_nibble(src_row[col + 1], x_start + col + 1, y_start + row); - dst_row[col / 2] = static_cast((p1 << 4U) | p0); - } - if (col < width) { // odd width: last column has no pair, low nibble unused - dst_row[col / 2] = use_quality - ? dither_to_nibble(src_row[col], x_start + col, y_start + row) - : dither_to_bw_nibble(src_row[col], x_start + col, y_start + row); + const int32_t display_y = y_start + row; + + for (int32_t col = 0; col < width; col++) { + const int32_t display_x = x_start + col; + const uint8_t nibble = use_quality + ? dither_to_nibble(src_row[col], display_x, display_y) + : dither_to_bw_nibble(src_row[col], display_x, display_y); + epd_draw_pixel(display_x, display_y, static_cast(nibble << 4), internal->framebuffer); } } @@ -228,7 +276,6 @@ static error_t papers3_display_draw_bitmap(Device* device, int32_t x_start, int3 }; power_on(internal); - epd_draw_rotated_image(update_area, internal->packed_buffer, internal->framebuffer); const auto draw_mode = use_quality ? config->quality_draw_mode : FAST_DRAW_MODE; auto draw_result = epd_hl_update_area( &internal->hl_state, @@ -237,7 +284,7 @@ static error_t papers3_display_draw_bitmap(Device* device, int32_t x_start, int3 update_area ); - commit_quality_mode_decision(internal, use_quality, draw_result == EPD_DRAW_SUCCESS); + commit_quality_mode_decision(internal, use_quality, draw_result == EPD_DRAW_SUCCESS, within_hold); return draw_result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE; } @@ -257,10 +304,9 @@ static DisplayColorFormat papers3_display_get_color_format(Device*) { } // epd_width()/epd_height() are the panel's native, unrotated dimensions; epd_rotated_display_ -// width()/height() swap them for EPD_ROT_PORTRAIT/INVERTED_PORTRAIT. epd_draw_rotated_image() -// clamps its input rect against the rotated dims and epd_draw_pixel() applies the rotation -// transform on top of that (see _rotate() in epdiy.c), so both LVGL's canvas size and -// draw_bitmap()'s rect must be in rotated-space, not native-space. +// width()/height() swap them for EPD_ROT_PORTRAIT/INVERTED_PORTRAIT. draw_bitmap()'s rect and +// LVGL's canvas size are both in this rotated space; epd_draw_pixel() (in draw_bitmap()) converts +// to native space per pixel via the panel's configured rotation. static uint16_t papers3_display_get_resolution_x(Device*) { return static_cast(epd_rotated_display_width()); } @@ -271,8 +317,8 @@ static uint16_t papers3_display_get_resolution_y(Device*) { static void papers3_display_get_frame_buffer(Device*, uint8_t, void** out_buffer) { // Not exposed via the generic fb-direct path: EPDiy's framebuffer is its own 4bpp packed - // format, not the DISPLAY_COLOR_FORMAT_GRAYSCALE8 (1 byte/pixel) this driver reports - see - // get_frame_buffer_count() and draw_bitmap()'s conversion. + // format, not the DISPLAY_COLOR_FORMAT_GRAYSCALE8 (1 byte/pixel) this driver reports. + // See get_frame_buffer_count() and draw_bitmap()'s conversion. *out_buffer = nullptr; } @@ -283,12 +329,15 @@ static uint8_t papers3_display_get_frame_buffer_count(Device*) { // endregion static const DisplayApi papers3_display_api = { - // PREFER_EXTERNAL_RAM: draw_bitmap() converts into packed_buffer before touching hardware, - // never DMAs from LVGL's pointer directly - frees LVGL's draw buffers from forced internal RAM. + // PREFER_EXTERNAL_RAM: draw_bitmap() dithers straight into internal->framebuffer (SPIRAM) + // and never DMAs from LVGL's pointer directly, freeing LVGL's draw buffers from forced + // internal RAM. .capabilities = DISPLAY_CAPABILITY_ON_OFF | DISPLAY_CAPABILITY_SLOW_REFRESH | DISPLAY_CAPABILITY_PREFER_EXTERNAL_RAM, .reset = papers3_display_reset, .init = papers3_display_init, .draw_bitmap = papers3_display_draw_bitmap, + .clear = papers3_display_clear, + .refresh = papers3_display_refresh, .mirror = nullptr, .swap_xy = nullptr, .get_swap_xy = nullptr, @@ -339,27 +388,22 @@ static error_t start(Device* device) { internal->hl_state = s_hl_state; internal->framebuffer = epd_hl_get_framebuffer(&internal->hl_state); - // Sized for the rotated (LVGL-facing) resolution - see get_resolution_x()/y()'s comment. - // ~260KB for this panel - a plain malloc() would land in scarce internal RAM. This buffer is - // only ever read once per draw_bitmap() call by epd_draw_rotated_image() (into epdiy's own - // SPIRAM-backed framebuffers, see highlevel.c), so it has no internal-RAM/DMA requirement and - // belongs in PSRAM instead, matching epdiy's own front_fb/back_fb/difference_fb allocations. - const size_t packed_buffer_size = static_cast((epd_rotated_display_width() + 1) / 2) * static_cast(epd_rotated_display_height()); - internal->packed_buffer = static_cast(heap_caps_malloc(packed_buffer_size, MALLOC_CAP_SPIRAM)); - if (internal->packed_buffer == nullptr) { - LOG_E(TAG, "Failed to allocate packed pixel buffer"); - epd_deinit(); - free(internal); - return ERROR_OUT_OF_MEMORY; - } - internal->panel_pixel_count = static_cast(epd_rotated_display_width()) * static_cast(epd_rotated_display_height()); internal->partial_count_since_quality = 0; - internal->last_quality_refresh_tick = get_ticks(); internal->quality_hold_until_tick = 0; + internal->quality_hold_session_start_tick = 0; device_set_driver_data(device, internal); + // The bootloader/boot-logo splash draws via partial refreshes that never get a real quality + // pass, leaving a faint ghost. Run the heavier flash-cycle clear now, before LVGL's first + // flush ever reaches draw_bitmap(), so it never has to undo content LVGL already put on + // screen. Nothing in the current boot path calls display_init() on any display device, so + // this is done directly here instead of the DisplayApi init() callback. Routine post-boot + // clears use the lighter papers3_display_clear(). + power_on(internal); + epd_fullclear(&internal->hl_state, config->temperature_celsius); + LOG_I(TAG, "EPDiy initialized (%dx%d native, %dx%d rotated)", epd_width(), epd_height(), epd_rotated_display_width(), epd_rotated_display_height()); return ERROR_NONE; } @@ -374,7 +418,6 @@ static error_t stop(Device* device) { epd_deinit(); - free(internal->packed_buffer); free(internal); device_set_driver_data(device, nullptr); return ERROR_NONE; diff --git a/Modules/lvgl-module/source/devices/devices.cpp b/Modules/lvgl-module/source/devices/devices.cpp index 364dc8759..cd18f78f6 100644 --- a/Modules/lvgl-module/source/devices/devices.cpp +++ b/Modules/lvgl-module/source/devices/devices.cpp @@ -26,6 +26,35 @@ struct LvglDeviceList { size_t count; }; +/** + * Fires periodically while a slow-refresh display (e.g. e-paper) is bound. Once + * lv_display_get_inactive_time(), the time since the last real input event tracked in lv_indev.c, + * crosses this threshold, invalidates the whole active screen so LVGL redraws every widget + * through its own draw_bitmap() calls, clearing ghosting that normal partial redraws leave behind + * over time. Checking real input activity rather than time since last draw means a redraw during + * active use, such as a blinking cursor, never resets the timer, so this only ever acts once + * genuinely idle. + */ +constexpr uint32_t IDLE_REPAINT_MS = 5000; + +struct IdleRepaintContext { + lv_display_t* lvgl_display; + struct Device* kernel_display_device; + lv_timer_t* timer; + // Set once the invalidate+lv_refr_now() repaint has run for the current idle stretch, so it + // isn't redone every tick while still idle. + bool repaint_done_since_activity; + // Set only once display_refresh() itself returns ERROR_NONE, tracked separately from + // repaint_done_since_activity so a failed refresh (e.g. ERROR_RESOURCE) keeps getting retried + // on later idle ticks instead of being silently skipped for the rest of the idle stretch. + bool refresh_done_since_activity; +}; + +// One slot per display bound in lvgl_devices_attach()'s loop below that turns out to be +// DISPLAY_CAPABILITY_SLOW_REFRESH; a board with multiple such displays needs independent idle +// tracking for each; slots are 1:1 with idle_repaint_contexts' index, not a compacted list. +static IdleRepaintContext idle_repaint_contexts[LVGL_DEVICES_MAX_PER_TYPE] = {}; + extern "C" { static bool lvgl_device_list_collect(struct Device* device, void* context) { @@ -36,6 +65,33 @@ static bool lvgl_device_list_collect(struct Device* device, void* context) { return true; } +static void idle_repaint_timer_cb(lv_timer_t* timer) { + auto* ctx = static_cast(lv_timer_get_user_data(timer)); + if (lv_display_get_inactive_time(ctx->lvgl_display) < IDLE_REPAINT_MS) { + ctx->repaint_done_since_activity = false; + ctx->refresh_done_since_activity = false; + return; + } + if (!ctx->repaint_done_since_activity) { + ctx->repaint_done_since_activity = true; + lv_obj_invalidate(lv_display_get_screen_active(ctx->lvgl_display)); + // lv_refr_now() runs the redraw synchronously, so display_refresh() below sees the + // now-current framebuffer instead of stale content from before the invalidate. + lv_refr_now(ctx->lvgl_display); + } + if (ctx->refresh_done_since_activity) { + return; + } + // Under PARTIAL render mode the repaint above still tiles into ordinary sub-threshold + // draw_bitmap() calls that stay in fast MODE_DU, which alone never fully clears prior + // ghosting even when it draws the correct pixels. This forces the quality pass that clears + // it. Tracked separately from the repaint so a failure here (e.g. ERROR_RESOURCE) retries on + // the next idle tick instead of also repeating the repaint, which succeeded already. + if (display_refresh(ctx->kernel_display_device) == ERROR_NONE) { + ctx->refresh_done_since_activity = true; + } +} + void lvgl_devices_attach() { lvgl_lock(); @@ -60,7 +116,7 @@ void lvgl_devices_attach() { color_format == DISPLAY_COLOR_FORMAT_BGR565; bool display_requires_full_frame = display_has_capability(kernel_display_device, DISPLAY_CAPABILITY_REQUIRES_FULL_FRAME); // Without CAP_SWAP_XY the driver can't rotate 90/270 in hardware (display_swap_xy() is - // null and silently skipped by lvgl_display_apply_rotation()) - LVGL would still switch + // null and silently skipped by lvgl_display_apply_rotation()). LVGL would still switch // its own logical w/h for those rotations, mismatching the panel's fixed physical // orientation (e.g. RGB/DPI panels, whose video timing is fixed at panel-init time). // sw_rotate makes LVGL rotate the rendered pixels in software instead, so the driver @@ -78,6 +134,22 @@ void lvgl_devices_attach() { lv_disp_t* added_display = NULL; if (lvgl_display_add(kernel_display_device, &lvgl_display_config, &added_display) == ERROR_NONE) { LOG_I(TAG, "Bound %s to LVGL", kernel_display_device->name); + // Slow-refresh panels accumulate ghosting from normal partial redraws over time, and + // worse from content an app drew directly via draw_bitmap() outside LVGL's own screen + // model, which LVGL has no way to know needs repainting. This function runs both at + // boot and whenever an app that stopped LVGL closes, so clearing here covers both + // cases without extra plumbing at app-close time specifically. + if (display_has_capability(kernel_display_device, DISPLAY_CAPABILITY_SLOW_REFRESH)) { + display_clear(kernel_display_device); + IdleRepaintContext* ctx = &idle_repaint_contexts[i]; + if (ctx->timer == nullptr) { + ctx->lvgl_display = added_display; + ctx->kernel_display_device = kernel_display_device; + ctx->repaint_done_since_activity = false; + ctx->refresh_done_since_activity = false; + ctx->timer = lv_timer_create(idle_repaint_timer_cb, 1000, ctx); + } + } // Pointers/keyboards below bind to the first display bound here, matching that display's // refresh behavior. if (lvgl_display == NULL) { @@ -181,6 +253,14 @@ void lvgl_devices_detach() { indev = lv_indev_get_next(NULL); } + for (size_t i = 0; i < LVGL_DEVICES_MAX_PER_TYPE; i++) { + IdleRepaintContext* ctx = &idle_repaint_contexts[i]; + if (ctx->timer != nullptr) { + lv_timer_delete(ctx->timer); + *ctx = {}; + } + } + lv_disp_t* display = lv_disp_get_next(NULL); while (display != NULL) { lvgl_display_remove(display); diff --git a/TactilityKernel/include/tactility/drivers/display.h b/TactilityKernel/include/tactility/drivers/display.h index 3518e53c8..4d9396b70 100644 --- a/TactilityKernel/include/tactility/drivers/display.h +++ b/TactilityKernel/include/tactility/drivers/display.h @@ -81,6 +81,30 @@ struct DisplayApi { */ error_t (*draw_bitmap)(struct Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data); + /** + * @brief Fully wipes/resets the panel's visible image, independent of anything previously + * drawn via draw_bitmap(). Intended for panels where content drawn outside the normal + * screen/widget model, such as an app writing directly via draw_bitmap(), can leave residue + * that nothing else knows to repaint. E-paper ghosting is the main case. + * @warning Nullable. When null, this display has no dedicated clear operation. Callers should + * fall back to redrawing (e.g. a full-frame draw_bitmap()) if a clear is needed. + * @param[in] device the display device + * @retval ERROR_NONE when the operation was successful + */ + error_t (*clear)(struct Device* device); + + /** + * @brief Forces a full-quality redraw of whatever is already in the panel's buffer, without + * changing its content. Unlike clear(), which wipes the image first, this is for e-paper + * panels doing a GC16-equivalent pass to clear ghosting left by earlier fast or partial + * draw_bitmap() calls, as periodic maintenance while content is otherwise correct but has + * accumulated visible residue from the panel's own display technology. + * @warning Nullable. When null, this display has no such maintenance pass. + * @param[in] device the display device + * @retval ERROR_NONE when the operation was successful + */ + error_t (*refresh)(struct Device* device); + /** * @brief Mirrors the image along the X and/or Y axis. * @warning Function pointer should be null if capability not available. @@ -254,6 +278,20 @@ error_t display_init(struct Device* device); */ error_t display_draw_bitmap(struct Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data); +/** + * @brief Fully wipes/resets the panel's visible image using the specified display. See + * DisplayApi::clear() for details. + * @retval ERROR_NOT_SUPPORTED when this display has no clear() implementation + */ +error_t display_clear(struct Device* device); + +/** + * @brief Forces a full-quality redraw of the panel's current content using the specified display. + * See DisplayApi::refresh() for details. + * @retval ERROR_NOT_SUPPORTED when this display has no refresh() implementation + */ +error_t display_refresh(struct Device* device); + /** * @brief Mirrors the image along the X and/or Y axis using the specified display. */ diff --git a/TactilityKernel/source/drivers/display.cpp b/TactilityKernel/source/drivers/display.cpp index aaa65ff5b..3dfecdf4a 100644 --- a/TactilityKernel/source/drivers/display.cpp +++ b/TactilityKernel/source/drivers/display.cpp @@ -30,6 +30,24 @@ error_t display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, in return DISPLAY_DRIVER_API(driver)->draw_bitmap(device, x_start, y_start, x_end, y_end, color_data); } +error_t display_clear(Device* device) { + const auto* driver = device_get_driver(device); + const auto* api = DISPLAY_DRIVER_API(driver); + if (api->clear == nullptr) { + return ERROR_NOT_SUPPORTED; + } + return api->clear(device); +} + +error_t display_refresh(Device* device) { + const auto* driver = device_get_driver(device); + const auto* api = DISPLAY_DRIVER_API(driver); + if (api->refresh == nullptr) { + return ERROR_NOT_SUPPORTED; + } + return api->refresh(device); +} + error_t display_mirror(Device* device, bool x_axis, bool y_axis) { const auto* driver = device_get_driver(device); return DISPLAY_DRIVER_API(driver)->mirror(device, x_axis, y_axis); diff --git a/TactilityKernel/source/symbols.c b/TactilityKernel/source/symbols.c index 9ec070509..895ec4f24 100644 --- a/TactilityKernel/source/symbols.c +++ b/TactilityKernel/source/symbols.c @@ -151,9 +151,12 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = { DEFINE_MODULE_SYMBOL(backlight_get_max_brightness), DEFINE_MODULE_SYMBOL(BACKLIGHT_TYPE), // drivers/display + DEFINE_MODULE_SYMBOL(display_has_capability), DEFINE_MODULE_SYMBOL(display_reset), DEFINE_MODULE_SYMBOL(display_init), DEFINE_MODULE_SYMBOL(display_draw_bitmap), + DEFINE_MODULE_SYMBOL(display_clear), + DEFINE_MODULE_SYMBOL(display_refresh), DEFINE_MODULE_SYMBOL(display_mirror), DEFINE_MODULE_SYMBOL(display_swap_xy), DEFINE_MODULE_SYMBOL(display_get_swap_xy),