// SPDX-License-Identifier: Apache-2.0 #include #include #include #include #include #include constexpr auto* TAG = "lvgl_pointer"; // Bus reads are expected to complete quickly; bound the wait so a stalled controller can't block the LVGL indev poll. static const TickType_t LVGL_POINTER_READ_TIMEOUT = pdMS_TO_TICKS(10); // Tracks the first indev created by lvgl_pointer_add() still alive, for lvgl_pointer_get_default(). // Only ever set/cleared by lvgl_pointer_add()/lvgl_pointer_remove(), so it can never point at an // indev created by other code (e.g. the deprecated HAL's own LVGL pointer registration). static lv_indev_t* default_pointer_indev = NULL; // Mirrors Tactility/Source/settings/TouchCalibrationSettings.cpp's isValid(). static const int32_t LVGL_POINTER_CALIBRATION_MIN_RANGE = 20; // Caps nearest-neighbor slot tracking (lvgl_pointer_pool_assign) to a fraction of screen width, // so a lifted finger's slot doesn't jump to grab an unrelated new touch elsewhere on screen. // Scaled by resolution, not a flat pixel value, so it stays proportionate on any display size. // Set generously \wide: a too-tight cap misreads a fast scroll's own motion as release+re-press, // firing whatever is under the finger mid-drag. // // \wide False positives (treating one continued drag as two separate touches) are far more // disruptive than false negatives (merging an unrelated same-spot lift+relanding), which // favors erring toward a larger cap. static const int32_t LVGL_POINTER_MAX_TRACK_DIST_FRACTION = 3; // 1/3 of screen width // One physical touch device backs LVGL_POINTER_MAX_SLOTS independent lv_indev_t instances (a // "pool"), so LVGL can track that many simultaneous fingers - LVGL v9 has no multi-point indev // concept (confirmed against lv_indev.h/lv_indev.c: lv_indev_data_t carries exactly one // lv_point_t/state pair), so N simultaneous independently-clickable widgets requires N indevs. // Each slot's read callback shares one raw multi-touch read per round rather than each slot // hitting the bus independently - see lvgl_pointer_read_cb(). struct LvglPointerPool { struct Device* device; bool calibration_enabled; struct LvglPointerCalibration calibration; uint8_t slot_count; lv_indev_t* slot_indev[LVGL_POINTER_MAX_SLOTS]; // Per-slot "which finger" tracking. A slot with active=false reports RELEASED and is up for // grabs by any unmatched raw point next round. bool slot_active[LVGL_POINTER_MAX_SLOTS]; lv_point_t slot_point[LVGL_POINTER_MAX_SLOTS]; // Shared raw-read cache for this round. Refreshed by whichever slot's read callback runs // first each round (see round_pos); the rest just consume it - one bus transaction per // slot_count read callbacks, not one per slot. uint8_t round_pos; uint16_t raw_x[LVGL_POINTER_MAX_SLOTS]; uint16_t raw_y[LVGL_POINTER_MAX_SLOTS]; uint8_t raw_count; }; // Safely narrows an arbitrary lv_indev_t's driver data down to this module's LvglPointerPool, // or NULL if indev is NULL, wasn't created by lvgl_pointer_add() (LvglDeviceContext is shared by // every LVGL indev/display this module creates - pointer, trackball, keyboard - so driver_data // alone doesn't prove it's a pointer pool), or is mid-teardown (context nulled by // lvgl_pointer_remove()/lvgl_pointer_add()'s cleanup path before the wrapper itself is freed). static struct LvglPointerPool* lvgl_pointer_pool_from_indev(lv_indev_t* indev) { if (indev == NULL) { return NULL; } auto* wrapper = (struct LvglDeviceContext*)lv_indev_get_driver_data(indev); if (wrapper == NULL || wrapper->device == NULL || device_get_type(wrapper->device) != &POINTER_TYPE) { return NULL; } return (struct LvglPointerPool*)wrapper->context; } static bool lvgl_pointer_calibration_is_valid(const struct LvglPointerCalibration* calibration) { return calibration->x_max > calibration->x_min && calibration->y_max > calibration->y_min && (calibration->x_max - calibration->x_min) >= LVGL_POINTER_CALIBRATION_MIN_RANGE && (calibration->y_max - calibration->y_min) >= LVGL_POINTER_CALIBRATION_MIN_RANGE; } // Linear per-axis rescale of [x_min,x_max]/[y_min,y_max] onto [0,target_x_max]/[0,target_y_max], // clamped. Mirrors TouchCalibrationSettings.cpp's applyCalibration(). Kept as a standalone // function (not inlined into the read callback) so the math is isolated and easy to reason about. static void lvgl_pointer_calibration_apply( const struct LvglPointerCalibration* calibration, int32_t target_x_max, int32_t target_y_max, uint16_t* x, uint16_t* y ) { int64_t mapped_x = ((int64_t)*x - calibration->x_min) * target_x_max / ((int64_t)calibration->x_max - calibration->x_min); int64_t mapped_y = ((int64_t)*y - calibration->y_min) * target_y_max / ((int64_t)calibration->y_max - calibration->y_min); if (mapped_x < 0) mapped_x = 0; if (mapped_x > target_x_max) mapped_x = target_x_max; if (mapped_y < 0) mapped_y = 0; if (mapped_y > target_y_max) mapped_y = target_y_max; *x = (uint16_t)mapped_x; *y = (uint16_t)mapped_y; } // Reads all currently-touched points from the device into pool->raw_x/raw_y/raw_count, applying // calibration in the graphics driver's own native (LV_DISPLAY_ROTATION_0) coordinate space - // native_x_max/native_y_max are just the panel's fixed pixel dimensions, not a rotation. This // function has no notion of LVGL rotation at all: calibration corrects the raw sensor's fixed // physical mapping, which never changes with on-screen orientation, so it doesn't belong anywhere // near rotation math. static void lvgl_pointer_pool_refresh(struct LvglPointerPool* pool, int32_t native_x_max, int32_t native_y_max) { pool->raw_count = 0; if (pointer_read_data(pool->device, LVGL_POINTER_READ_TIMEOUT) != ERROR_NONE) { return; } uint8_t point_count = 0; if (!pointer_get_touched_points(pool->device, pool->raw_x, pool->raw_y, NULL, &point_count, LVGL_POINTER_MAX_SLOTS) || point_count == 0) { return; } if (point_count > LVGL_POINTER_MAX_SLOTS) point_count = LVGL_POINTER_MAX_SLOTS; if (pool->calibration_enabled && native_x_max > 0 && native_y_max > 0) { for (uint8_t i = 0; i < point_count; i++) { lvgl_pointer_calibration_apply(&pool->calibration, native_x_max, native_y_max, &pool->raw_x[i], &pool->raw_y[i]); } } pool->raw_count = point_count; } // Matches this round's raw points onto pool slots by nearest-neighbor to each slot's last known // position, so a slot "follows" the same physical finger across rounds instead of jumping when // the touch controller reports points in a different order (no touch-ID/tracking field exists // anywhere in this stack - see esp_lcd_touch_get_coordinates()/PointerApi.get_touched_points()). // Unmatched raw points (new touches) claim the nearest inactive slot. Slots with no matching // point this round go inactive (RELEASED). static void lvgl_pointer_pool_assign(struct LvglPointerPool* pool, int32_t native_x_max) { // Touch drivers clamp raw coordinates to the panel's configured native resolution regardless // of calibration, so native_x_max is a valid scale reference for the distance cap even when calibration is disabled. // Falls back to a conservative fixed pixel value if the display/resolution isn't available for some reason. const int32_t max_track_dist = native_x_max > 0 ? (native_x_max / LVGL_POINTER_MAX_TRACK_DIST_FRACTION) : 150; const int32_t max_track_dist_sq = max_track_dist * max_track_dist; bool raw_claimed[LVGL_POINTER_MAX_SLOTS] = {}; // A slot released this round must report RELEASED for at least one round before it can host // a new touch - otherwise pass 2 immediately reassigns it, and LVGL sees a jump instead of a // release-then-press. bool slot_released_now[LVGL_POINTER_MAX_SLOTS] = {}; // First pass: let already-active slots keep following their nearest raw point, so a held // finger doesn't get reshuffled onto a different slot just because another finger moved. for (uint8_t s = 0; s < pool->slot_count; s++) { if (!pool->slot_active[s]) continue; int32_t best_dist = -1; int8_t best_raw = -1; for (uint8_t r = 0; r < pool->raw_count; r++) { if (raw_claimed[r]) continue; int32_t dx = (int32_t)pool->raw_x[r] - pool->slot_point[s].x; int32_t dy = (int32_t)pool->raw_y[r] - pool->slot_point[s].y; int32_t dist = dx * dx + dy * dy; if (best_raw < 0 || dist < best_dist) { best_dist = dist; best_raw = (int8_t)r; } } if (best_raw >= 0 && best_dist <= max_track_dist_sq) { raw_claimed[best_raw] = true; pool->slot_point[s].x = (lv_coord_t)pool->raw_x[best_raw]; pool->slot_point[s].y = (lv_coord_t)pool->raw_y[best_raw]; } else { pool->slot_active[s] = false; slot_released_now[s] = true; } } // Second pass: any unclaimed raw point is a new touch - hand it to the first inactive slot // that wasn't just released this round. for (uint8_t r = 0; r < pool->raw_count; r++) { if (raw_claimed[r]) continue; for (uint8_t s = 0; s < pool->slot_count; s++) { if (pool->slot_active[s] || slot_released_now[s]) continue; pool->slot_active[s] = true; pool->slot_point[s].x = (lv_coord_t)pool->raw_x[r]; pool->slot_point[s].y = (lv_coord_t)pool->raw_y[r]; raw_claimed[r] = true; break; } } } // The actual LVGL indev read callback, shared by every slot in the pool. Only the first slot to // be read each round (round_pos wraps 0..slot_count-1) triggers the real bus read + reassignment; // the rest just report whatever lvgl_pointer_pool_assign() decided for their slot. Which slot // happens to run first varies (LVGL calls each indev's timer independently), but since all slots // share one timer period they complete one full round every slot_count calls regardless of order. static void lvgl_pointer_read_cb(lv_indev_t* indev, lv_indev_data_t* data) { struct LvglPointerPool* pool = lvgl_pointer_pool_from_indev(indev); if (pool == NULL) { data->state = LV_INDEV_STATE_RELEASED; return; } uint8_t slot = 0; for (; slot < pool->slot_count; slot++) { if (pool->slot_indev[slot] == indev) break; } if (pool->round_pos == 0) { lv_display_t* display = lv_indev_get_display(indev); // lv_display_get_original_*_resolution() is the native (LV_DISPLAY_ROTATION_0) size, // unaffected by the display's current rotation - no rotation lookup needed to get it. int32_t native_x_max = display != NULL ? lv_display_get_original_horizontal_resolution(display) - 1 : 0; int32_t native_y_max = display != NULL ? lv_display_get_original_vertical_resolution(display) - 1 : 0; lvgl_pointer_pool_refresh(pool, native_x_max, native_y_max); lvgl_pointer_pool_assign(pool, native_x_max); } pool->round_pos = (uint8_t)((pool->round_pos + 1) % pool->slot_count); if (slot < pool->slot_count && pool->slot_active[slot]) { data->point = pool->slot_point[slot]; data->state = LV_INDEV_STATE_PRESSED; } else { data->state = LV_INDEV_STATE_RELEASED; } } error_t lvgl_pointer_add(struct Device* device, lv_display_t* display, uint8_t max_touch_points, lv_indev_t** out_indevs) { if (device == NULL || out_indevs == NULL) { return ERROR_INVALID_ARGUMENT; } if (device_get_type(device) != &POINTER_TYPE) { return ERROR_INVALID_ARGUMENT; } if (max_touch_points == 0) max_touch_points = 1; if (max_touch_points > LVGL_POINTER_MAX_SLOTS) max_touch_points = LVGL_POINTER_MAX_SLOTS; auto* pool = new(std::nothrow) LvglPointerPool(); if (pool == NULL) { return ERROR_OUT_OF_MEMORY; } pool->device = device; pool->slot_count = max_touch_points; // Every slot gets its own LvglDeviceContext wrapper, but all of them point at this same pool. // On the way out - success or failure - every wrapper's context pointer is nulled before any // of them are deleted, then the pool itself is freed exactly once: LvglDeviceContext's // destructor does `::operator delete(context)`, so leaving more than one wrapper owning the // same pool pointer would double-free it (see lvgl_pointer_remove(), same pattern). uint8_t created = 0; for (; created < max_touch_points; created++) { auto* wrapper = new(std::nothrow) LvglDeviceContext(pool); if (wrapper == NULL) { break; } wrapper->device = device; lv_indev_t* indev = lv_indev_create(); if (indev == NULL) { delete wrapper; break; } lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); lv_indev_set_read_cb(indev, lvgl_pointer_read_cb); lv_indev_set_driver_data(indev, wrapper); if (display != NULL) { lv_indev_set_display(indev, display); } pool->slot_indev[created] = indev; out_indevs[created] = indev; } if (created < max_touch_points) { for (uint8_t j = 0; j < created; j++) { lv_indev_t* slot_indev = pool->slot_indev[j]; auto* slot_wrapper = (LvglDeviceContext*)lv_indev_get_driver_data(slot_indev); slot_wrapper->context = NULL; lv_indev_delete(slot_indev); delete slot_wrapper; } delete pool; return ERROR_OUT_OF_MEMORY; } if (default_pointer_indev == NULL) { default_pointer_indev = pool->slot_indev[0]; } return ERROR_NONE; } lv_indev_t* lvgl_pointer_get_default(void) { return default_pointer_indev; } // Any slot indev's wrapper->context points at the same shared pool, so calibration set through // any one of them applies to the whole physical device/all its slots (one panel, one calibration). error_t lvgl_pointer_set_calibration(lv_indev_t* indev, const struct LvglPointerCalibration* calibration) { struct LvglPointerPool* pool = lvgl_pointer_pool_from_indev(indev); if (pool == NULL) { return ERROR_INVALID_ARGUMENT; } if (calibration == NULL) { pool->calibration_enabled = false; return ERROR_NONE; } if (!lvgl_pointer_calibration_is_valid(calibration)) { return ERROR_INVALID_ARGUMENT; } pool->calibration = *calibration; pool->calibration_enabled = true; return ERROR_NONE; } bool lvgl_pointer_get_calibration(lv_indev_t* indev, struct LvglPointerCalibration* out_calibration) { if (out_calibration == NULL) { return false; } struct LvglPointerPool* pool = lvgl_pointer_pool_from_indev(indev); if (pool == NULL || !pool->calibration_enabled) { return false; } *out_calibration = pool->calibration; return true; } int8_t lvgl_pointer_get_slot_index(lv_indev_t* indev) { struct LvglPointerPool* pool = lvgl_pointer_pool_from_indev(indev); if (pool == NULL) { return -1; } for (uint8_t i = 0; i < pool->slot_count; i++) { if (pool->slot_indev[i] == indev) return (int8_t)i; } return -1; } // Removes every slot indev belonging to the same pool as `indev` (a partial pool removal isn't a // case that comes up: apps ask for "the pointer device" and get every slot back from // lvgl_pointer_add(), so they hold either all of a pool's indevs or none). void lvgl_pointer_remove(lv_indev_t* indev) { if (indev == NULL) { return; } struct LvglDeviceContext* wrapper = (struct LvglDeviceContext*)lv_indev_get_driver_data(indev); struct LvglPointerPool* pool = (struct LvglPointerPool*)wrapper->context; uint8_t slot_count = pool->slot_count; lv_indev_t* slot_indevs[LVGL_POINTER_MAX_SLOTS]; memcpy(slot_indevs, pool->slot_indev, sizeof(lv_indev_t*) * slot_count); // Every LvglDeviceContext wrapper points at the same pool; null `context` out on all of them // before deleting any (LvglDeviceContext's destructor frees `context`, and pool must stay // valid for every wrapper's own delete to run safely) - then free the pool exactly once // ourselves at the end. for (uint8_t i = 0; i < slot_count; i++) { lv_indev_t* slot_indev = slot_indevs[i]; if (slot_indev == NULL) continue; if (default_pointer_indev == slot_indev) { default_pointer_indev = NULL; } struct LvglDeviceContext* slot_wrapper = (struct LvglDeviceContext*)lv_indev_get_driver_data(slot_indev); slot_wrapper->context = NULL; lv_indev_delete(slot_indev); delete slot_wrapper; } delete pool; }