diff --git a/helper/README.md b/helper/README.md new file mode 100644 index 0000000..eb20649 --- /dev/null +++ b/helper/README.md @@ -0,0 +1,48 @@ +# Helper + +## Introduction + +This subdirectory delivers simple helper programs. + +## progressive_to_normal_jpegs.sh + +Converts progressive jpegs into non progressive ones. + +### Requirements: +Ensure jpegtran is installed. +- Linux + - Arch: + >sudo pacman -S libjpeg-turbo + - Debian: + >sudo apt install libjpeg-turbo-progs +- macOS: + >brew install jpeg + +### Usage: +./progressive_to_normal_jpegs.sh /path/to/images + +## resize.py + +Handy tool to resize images to displays size (480x800). + +### Disclaimer: + +This tool takes 1 command line argument, the directory where the files-to-be-converted are stored. + +### Requirements: + +Virtual Environment (optional) +- create a venv: + >python -m venv .env +- activate venv + - Linux/macOS: + >source .env/bin/activate + - Windows + >./.env/Scripts/activate.bat + +Python-Pillow +>pip install pillow + +### Usage: + +>python resize.py /path/to/images \ No newline at end of file diff --git a/helper/progressive_to_normal_jpeg.sh b/helper/progressive_to_normal_jpeg.sh new file mode 100644 index 0000000..a9b8795 --- /dev/null +++ b/helper/progressive_to_normal_jpeg.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +DIR="$1" + +if [ -z "$DIR" ]; then + echo "Usage: $0 /path/to/images" + exit 1 +fi + +if [ ! -d "$DIR" ]; then + echo "Error: Directory does not exist: $DIR" + exit 1 +fi + +for i in "$DIR"/*.jpeg "$DIR"/*.jpg; do + [ -e "$i" ] || continue + jpegtran -copy none -optimize -outfile "${i%.*}_out.${i##*.}" "$i" +done \ No newline at end of file diff --git a/helper/resize.py b/helper/resize.py new file mode 100644 index 0000000..302496f --- /dev/null +++ b/helper/resize.py @@ -0,0 +1,20 @@ +from PIL import Image +import os +import sys + +TARGET = (480, 800) +INPUT_DIR = sys.argv[1] if len(sys.argv) > 1 else "." +EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} + +for filename in os.listdir(INPUT_DIR): + ext = os.path.splitext(filename)[1].lower() + if ext not in EXTENSIONS: + continue + + path = os.path.join(INPUT_DIR, filename) + img = Image.open(path) + + img.thumbnail(TARGET, Image.LANCZOS) + + img.save(path) + print(f"{filename}: {img.size}") diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 7bab7b2..feef098 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,3 +1,3 @@ -idf_component_register(SRCS main.c lcd/lcd.c sd/sd.c lcd/ui.c +idf_component_register(SRCS main.c lcd/lcd.c sd/sd.c lcd/ui.c lcd/ui_log.c lcd/ui_image_browser.c PRIV_REQUIRES spi_flash esp_psram esp_lcd esp_driver_gpio esp_driver_i2c esp_driver_sdspi esp_driver_spi esp_lvgl_port esp_lcd_touch_gt911 fatfs sdmmc INCLUDE_DIRS "." "include") diff --git a/main/include/lcd/ui.h b/main/include/lcd/ui.h index 01a42d2..1a69cd7 100644 --- a/main/include/lcd/ui.h +++ b/main/include/lcd/ui.h @@ -1,12 +1,12 @@ -#pragma once +#pragma once + +#include "lcd/ui_log.h" typedef enum { UI_VIEW_LOG, UI_VIEW_IMAGE_BROWSER, } ui_view_t; -void ui_log_append(const char *text); -void ui_log_appendf(const char *format, ...); void create_log_ui(void); void create_image_browser_ui(void); diff --git a/main/include/lcd/ui_image_browser.h b/main/include/lcd/ui_image_browser.h new file mode 100644 index 0000000..472ee96 --- /dev/null +++ b/main/include/lcd/ui_image_browser.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +#include "lvgl.h" + +// Builds the image-browser view (tileview with tiles, or the empty-state +// placeholder) onto the given screen. The caller must hold the LVGL lock. +void ui_image_browser_build_view(lv_obj_t *screen); + +// Stops the slideshow and clears browser state; call before the active screen +// is cleared. +void ui_image_browser_reset(void); + +// Number of images shown by the most recent build (used for logging). +size_t ui_image_browser_get_image_count(void); diff --git a/main/include/lcd/ui_internal.h b/main/include/lcd/ui_internal.h new file mode 100644 index 0000000..5ce281a --- /dev/null +++ b/main/include/lcd/ui_internal.h @@ -0,0 +1,12 @@ +#pragma once + +#include "lvgl.h" +#include "lcd/ui.h" + +// Shared coordinator helpers used by the log and image-browser view modules. + +// Makes an object toggle between the log and image-browser views when clicked. +void ui_attach_toggle(lv_obj_t *object); + +// Returns the currently active view. +ui_view_t ui_current_view(void); diff --git a/main/include/lcd/ui_log.h b/main/include/lcd/ui_log.h new file mode 100644 index 0000000..84c7450 --- /dev/null +++ b/main/include/lcd/ui_log.h @@ -0,0 +1,15 @@ +#pragma once + +#include "lvgl.h" + +// Appends a line to the scrollback log. Safe to call before the log view +// exists and from any task; the line is always stored in the ring buffer. +void ui_log_append(const char *text); +void ui_log_appendf(const char *format, ...); + +// Builds the log view (title + textarea) onto the given screen and restores +// the ring-buffer history. The caller must hold the LVGL lock. +void ui_log_build_view(lv_obj_t *screen); + +// Drops the textarea reference; call before the active screen is cleared. +void ui_log_detach(void); diff --git a/main/lcd/ui.c b/main/lcd/ui.c index b7e77e4..f0aab58 100644 --- a/main/lcd/ui.c +++ b/main/lcd/ui.c @@ -1,404 +1,58 @@ -#include -#include -#include -#include -#include -#include - #include "lvgl.h" -#include "lcd/lcd.h" #include "esp_lvgl_port.h" #include "esp_log.h" + #include "lcd/ui.h" -#include "sd/sd.h" +#include "lcd/ui_internal.h" +#include "lcd/ui_log.h" +#include "lcd/ui_image_browser.h" -#define UI_LOG_MAX_LINES 41 -#define UI_LOG_LINE_MAX_LENGTH 160 -#define UI_IMAGE_PATH_PREFIX "A:/sdcard/" -#define UI_IMAGE_PATH_MAX_LENGTH 160 -#define UI_IMAGE_INFO_BAR_HEIGHT 42 -#define UI_IMAGE_INFO_BAR_BOTTOM_OFFSET 10 -#define UI_IMAGE_PROBE_HEADER_LENGTH 16 -#define UI_SLIDESHOW_PERIOD_MS 3000 - -static lv_obj_t *log_textarea = NULL; -static lv_obj_t *image_tileview = NULL; -static lv_timer_t *image_slideshow_timer = NULL; -static char log_lines[UI_LOG_MAX_LINES][UI_LOG_LINE_MAX_LENGTH]; -static size_t log_line_length = 0; -static size_t log_line_start = 0; -static size_t image_browser_image_count = 0; -static size_t image_browser_current_index = 0; static const char *TAG = "ui"; - static ui_view_t current_view = UI_VIEW_LOG; -static void ui_create_log_ui_locked(void); -static void ui_create_image_browser_ui_locked(void); +static void ui_build_log_view_locked(void); +static void ui_build_image_browser_view_locked(void); -static size_t ui_log_buffer_index(size_t line_offset){ - return (log_line_start + line_offset) % UI_LOG_MAX_LINES; -} - -static void ui_log_store_line(const char *text){ - if(text == NULL) return; - - size_t line_index; - if(log_line_length < UI_LOG_MAX_LINES){ - line_index = ui_log_buffer_index(log_line_length); - log_line_length++; - } - else{ - line_index = log_line_start; - log_line_start = ui_log_buffer_index(1); - } - - snprintf(log_lines[line_index], sizeof(log_lines[line_index]), "%s", text); -} - -static void ui_log_render_locked(void){ - if(log_textarea == NULL) return; - - lv_textarea_set_text(log_textarea, ""); - for(size_t line_offset = 0; line_offset < log_line_length; line_offset++){ - lv_textarea_add_text(log_textarea, log_lines[ui_log_buffer_index(line_offset)]); - lv_textarea_add_text(log_textarea, "\n"); - } - lv_textarea_set_cursor_pos(log_textarea, LV_TEXTAREA_CURSOR_LAST); -} - -static void ui_reset_active_screen_locked(void){ - if(image_slideshow_timer != NULL){ - lv_timer_delete(image_slideshow_timer); - image_slideshow_timer = NULL; - } - - lv_obj_t *screen = lv_screen_active(); - lv_obj_clean(screen); - log_textarea = NULL; - image_tileview = NULL; - image_browser_image_count = 0; - image_browser_current_index = 0; -} - -static void ui_image_browser_set_index_locked(size_t image_index, lv_anim_enable_t anim_en){ - if(image_tileview == NULL || image_browser_image_count == 0) return; - - image_browser_current_index = image_index % image_browser_image_count; - lv_tileview_set_tile_by_index(image_tileview, 0, (uint32_t)image_browser_current_index, anim_en); -} - -static void ui_image_slideshow_timer_cb(lv_timer_t *timer){ - (void)timer; - if(current_view != UI_VIEW_IMAGE_BROWSER) return; - ui_image_browser_set_index_locked(image_browser_current_index + 1, LV_ANIM_ON); -} - -static void ui_start_image_slideshow_locked(void){ - if(image_slideshow_timer != NULL || image_browser_image_count <= 1) return; - image_slideshow_timer = lv_timer_create(ui_image_slideshow_timer_cb, UI_SLIDESHOW_PERIOD_MS, NULL); -} - -static void ui_toggle_view_locked(void){ - if(current_view == UI_VIEW_IMAGE_BROWSER) ui_create_log_ui_locked(); - else ui_create_image_browser_ui_locked(); +ui_view_t ui_current_view(void){ + return current_view; } static void ui_toggle_view_event_cb(lv_event_t *event){ if(lv_event_get_code(event) != LV_EVENT_CLICKED) return; - ui_toggle_view_locked(); + + if(current_view == UI_VIEW_IMAGE_BROWSER) ui_build_log_view_locked(); + else ui_build_image_browser_view_locked(); } -static void ui_add_toggle_click(lv_obj_t *object){ +void ui_attach_toggle(lv_obj_t *object){ lv_obj_add_flag(object, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(object, ui_toggle_view_event_cb, LV_EVENT_CLICKED, NULL); } -static void ui_image_browser_value_changed_event_cb(lv_event_t *event){ - if(lv_event_get_code(event) != LV_EVENT_VALUE_CHANGED) return; - - lv_obj_t *tileview = lv_event_get_current_target(event); - lv_obj_t *active_tile = lv_tileview_get_tile_active(tileview); - if(active_tile == NULL) return; - - image_browser_current_index = (size_t)(uintptr_t)lv_obj_get_user_data(active_tile); +static void ui_reset_active_screen_locked(void){ + ui_image_browser_reset(); + ui_log_detach(); + lv_obj_clean(lv_screen_active()); } -static bool ui_char_equals_ignore_case(char left_char, char right_char){ - if(left_char >= 'A' && left_char <= 'Z') left_char = (char)(left_char + ('a' - 'A')); - if(right_char >= 'A' && right_char <= 'Z') right_char = (char)(right_char + ('a' - 'A')); - return left_char == right_char; -} - -static bool ui_has_extension(const char *filename, const char *extension){ - if(filename == NULL || extension == NULL) return false; - - const size_t filename_length = strlen(filename); - const size_t extension_length = strlen(extension); - if(filename_length < extension_length) return false; - - const char *filename_extension = filename + filename_length - extension_length; - for(size_t char_index = 0; char_index < extension_length; char_index++){ - if(!ui_char_equals_ignore_case(filename_extension[char_index], extension[char_index])) return false; - } - - return true; -} - -static void ui_lowercase_path_extension(char *path){ - char *extension = strrchr(path, '.'); - if(extension == NULL) return; - - for(char *current_char = extension + 1; *current_char != '\0'; current_char++){ - if(*current_char >= 'A' && *current_char <= 'Z') *current_char = (char)(*current_char + ('a' - 'A')); - } -} - -static bool ui_is_image_file(const char *filename){ - return ui_has_extension(filename, ".bmp") || - ui_has_extension(filename, ".jpg") || - ui_has_extension(filename, ".jpeg") || - ui_has_extension(filename, ".png"); -} - -static const char *ui_detect_image_signature(const uint8_t *header, size_t header_length){ - if(header_length >= 2 && header[0] == 0xff && header[1] == 0xd8) return "jpeg"; - if(header_length >= 8 && header[0] == 0x89 && header[1] == 'P' && header[2] == 'N' && header[3] == 'G') return "png"; - if(header_length >= 2 && header[0] == 'B' && header[1] == 'M') return "bmp"; - return "unknown"; -} - -static long ui_get_open_file_size(FILE *file){ - if(file == NULL) return -1; - - const long current_position = ftell(file); - if(current_position < 0) return -1; - if(fseek(file, 0, SEEK_END) != 0) return -1; - - const long file_size = ftell(file); - fseek(file, current_position, SEEK_SET); - return file_size; -} - -static const char *ui_probe_image_load_failure(const char *filename, const char *image_source){ - if(filename == NULL || image_source == NULL) return "Invalid image path"; - - errno = 0; - FILE *file = sd_open_read(filename); - if(file == NULL){ - const int open_errno = errno; - ESP_LOGW(TAG, "Image file not readable via sd_open_read: file=%s source=%s errno=%d", filename, image_source, open_errno); - ui_log_appendf("Image read failed: %s errno=%d", filename, open_errno); - return "File not readable"; - } - - uint8_t header_bytes[UI_IMAGE_PROBE_HEADER_LENGTH] = {0}; - const size_t header_length = fread(header_bytes, 1, sizeof(header_bytes), file); - const long file_size = ui_get_open_file_size(file); - sd_close_file(file); - - const char *detected_format = ui_detect_image_signature(header_bytes, header_length); - ESP_LOGW(TAG, - "Image probe: file=%s source=%s size=%ld header=%02x %02x %02x %02x %02x %02x %02x %02x detected=%s", - filename, - image_source, - file_size, - (unsigned int)header_bytes[0], - (unsigned int)header_bytes[1], - (unsigned int)header_bytes[2], - (unsigned int)header_bytes[3], - (unsigned int)header_bytes[4], - (unsigned int)header_bytes[5], - (unsigned int)header_bytes[6], - (unsigned int)header_bytes[7], - detected_format); - ui_log_appendf("Image probe: %s size=%ld detected=%s", filename, file_size, detected_format); - - lv_image_header_t image_header; - const lv_result_t decoder_result = lv_image_decoder_get_info(image_source, &image_header); - if(decoder_result != LV_RESULT_OK){ - ESP_LOGW(TAG, "LVGL decoder rejected image: source=%s result=%d", image_source, (int)decoder_result); - ui_log_appendf("Decoder rejected: %s result=%d", filename, (int)decoder_result); - return "Decoder rejected image"; - } - - ESP_LOGW(TAG, - "LVGL decoder accepted image but widget has no size: source=%s width=%u height=%u cf=%u stride=%u flags=0x%x", - image_source, - (unsigned int)image_header.w, - (unsigned int)image_header.h, - (unsigned int)image_header.cf, - (unsigned int)image_header.stride, - (unsigned int)image_header.flags); - ui_log_appendf("Decoder info OK: %s %ux%u", filename, (unsigned int)image_header.w, (unsigned int)image_header.h); - return "Decoder info OK"; -} - -static size_t ui_count_sd_images(void){ - const size_t file_count = sd_get_file_count(); - size_t image_count = 0; - - for(size_t file_index = 0; file_index < file_count; file_index++){ - const char *filename = sd_get_next_file(); - if(ui_is_image_file(filename)) image_count++; - } - - return image_count; -} - -static void ui_create_empty_image_browser_locked(lv_obj_t *screen){ - ui_add_toggle_click(screen); - - lv_obj_t *label = lv_label_create(screen); - lv_label_set_text(label, "No images found on SD card"); - lv_obj_set_style_text_color(label, lv_color_hex(0xdde7ef), 0); - ui_add_toggle_click(label); - lv_obj_center(label); -} - -static lv_dir_t ui_get_tile_directions(size_t image_index, size_t image_count){ - if(image_count <= 1) return LV_DIR_NONE; - if(image_index == 0) return LV_DIR_BOTTOM; - if(image_index + 1 == image_count) return LV_DIR_TOP; - return (lv_dir_t)(LV_DIR_TOP | LV_DIR_BOTTOM); -} - -static void ui_create_image_tile_locked(lv_obj_t *tile, const char *filename, size_t image_index, size_t image_count){ - ui_add_toggle_click(tile); - lv_obj_set_style_bg_color(tile, lv_color_hex(0x070a0d), 0); - lv_obj_set_style_pad_all(tile, 0, 0); - lv_obj_remove_flag(tile, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t *image_frame = lv_obj_create(tile); - ui_add_toggle_click(image_frame); - lv_obj_set_size(image_frame, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_bg_color(image_frame, lv_color_black(), 0); - lv_obj_set_style_border_width(image_frame, 0, 0); - lv_obj_set_style_radius(image_frame, 0, 0); - lv_obj_set_style_pad_all(image_frame, 0, 0); - lv_obj_remove_flag(image_frame, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_center(image_frame); - - lv_obj_t *image = lv_image_create(image_frame); - ui_add_toggle_click(image); - lv_obj_set_size(image, LV_PCT(100), LV_PCT(100)); - lv_image_set_inner_align(image, LV_IMAGE_ALIGN_CONTAIN); - - char image_source[UI_IMAGE_PATH_MAX_LENGTH]; - snprintf(image_source, sizeof(image_source), "%s%s", UI_IMAGE_PATH_PREFIX, filename); - ui_lowercase_path_extension(image_source); - lv_image_set_src(image, image_source); - lv_obj_center(image); - - lv_obj_t *info_bar = lv_obj_create(tile); - ui_add_toggle_click(info_bar); - lv_obj_set_size(info_bar, LV_PCT(100), UI_IMAGE_INFO_BAR_HEIGHT); - lv_obj_align(info_bar, LV_ALIGN_BOTTOM_MID, 0, -UI_IMAGE_INFO_BAR_BOTTOM_OFFSET); - lv_obj_set_style_bg_color(info_bar, lv_color_hex(0x101418), 0); - lv_obj_set_style_bg_opa(info_bar, LV_OPA_80, 0); - lv_obj_set_style_border_width(info_bar, 0, 0); - lv_obj_set_style_radius(info_bar, 0, 0); - lv_obj_set_style_pad_hor(info_bar, 14, 0); - lv_obj_set_style_pad_ver(info_bar, 0, 0); - lv_obj_remove_flag(info_bar, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t *caption = lv_label_create(info_bar); - ui_add_toggle_click(caption); - lv_label_set_long_mode(caption, LV_LABEL_LONG_DOT); - lv_obj_set_width(caption, LV_PCT(100)); - lv_obj_set_style_text_color(caption, lv_color_hex(0xdde7ef), 0); - lv_label_set_text_fmt(caption, "%u/%u %s", (unsigned int)(image_index + 1), (unsigned int)image_count, filename); - lv_obj_center(caption); - - if(lv_image_get_src_width(image) <= 0 || lv_image_get_src_height(image) <= 0){ - const char *load_reason = ui_probe_image_load_failure(filename, image_source); - ESP_LOGW(TAG, "Image cannot be loaded by LVGL: %s reason=%s", image_source, load_reason); - ui_log_appendf("Image load failed: %s (%s)", filename, load_reason); - lv_obj_t *error_label = lv_label_create(tile); - ui_add_toggle_click(error_label); - lv_label_set_text_fmt(error_label, "Image cannot be loaded\n%s\n%s", filename, load_reason); - lv_obj_set_style_text_color(error_label, lv_color_hex(0xffd166), 0); - lv_obj_set_style_text_align(error_label, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_width(error_label, LV_PCT(86)); - lv_obj_align(error_label, LV_ALIGN_CENTER, 0, -18); - } -} - -static void ui_create_image_browser_tiles_locked(size_t image_count){ - const size_t file_count = sd_get_file_count(); - size_t image_index = 0; - - for(size_t file_index = 0; file_index < file_count && image_index < image_count; file_index++){ - const char *filename = sd_get_next_file(); - if(!ui_is_image_file(filename)) continue; - - lv_obj_t *tile = lv_tileview_add_tile(image_tileview, 0, (uint32_t)image_index, ui_get_tile_directions(image_index, image_count)); - lv_obj_set_user_data(tile, (void *)(uintptr_t)image_index); - ui_create_image_tile_locked(tile, filename, image_index, image_count); - image_index++; - } -} - -void ui_log_append(const char *text){ - if(text == NULL) return; - - ui_log_store_line(text); - if(log_textarea == NULL) return; - - lvgl_port_lock(0); - ui_log_render_locked(); - lvgl_port_unlock(); -} - -void ui_log_appendf(const char *format, ...){ - if(format == NULL) return; - - char line[UI_LOG_LINE_MAX_LENGTH]; - va_list args; - va_start(args, format); - vsnprintf(line, sizeof(line), format, args); - va_end(args); - - ui_log_append(line); -} - -static void ui_create_log_ui_locked(void){ +static void ui_build_log_view_locked(void){ ui_reset_active_screen_locked(); - - lv_obj_t *screen = lv_screen_active(); - ui_add_toggle_click(screen); - lv_obj_set_style_bg_color(screen, lv_color_hex(0x101418), 0); - - lv_obj_t *title = lv_label_create(screen); - ui_add_toggle_click(title); - lv_label_set_text(title, "SD Image Viewer"); - lv_obj_set_style_text_color(title, lv_color_white(), 0); - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 14); - - if(log_textarea == NULL){ - log_textarea = lv_textarea_create(screen); - ui_add_toggle_click(log_textarea); - lv_obj_set_size(log_textarea, 440, 730); - lv_obj_align(log_textarea, LV_ALIGN_TOP_LEFT, 20, 40); - lv_textarea_set_one_line(log_textarea, false); - lv_textarea_set_cursor_click_pos(log_textarea, false); - lv_textarea_set_text(log_textarea, ""); - lv_obj_set_style_bg_color(log_textarea, lv_color_hex(0x182028), 0); - lv_obj_set_style_text_color(log_textarea, lv_color_hex(0xdde7ef), 0); - lv_obj_set_style_border_color(log_textarea, lv_color_hex(0x4b6478), 0); - lv_obj_set_style_radius(log_textarea, 4, 0); - lv_obj_set_style_pad_all(log_textarea, 12, 0); - } - ui_log_render_locked(); - + ui_log_build_view(lv_screen_active()); current_view = UI_VIEW_LOG; } +static void ui_build_image_browser_view_locked(void){ + ui_reset_active_screen_locked(); + ui_image_browser_build_view(lv_screen_active()); + current_view = UI_VIEW_IMAGE_BROWSER; +} + void create_log_ui(void){ ESP_LOGI(TAG, "Creating log UI"); ui_log_append("Creating log UI"); + lvgl_port_lock(0); - ui_create_log_ui_locked(); + ui_build_log_view_locked(); lvgl_port_unlock(); ui_log_append("Boot UI ready"); @@ -406,43 +60,17 @@ void create_log_ui(void){ ui_log_append("Log UI created"); } -static void ui_create_image_browser_ui_locked(void){ - const size_t image_count = ui_count_sd_images(); - ui_reset_active_screen_locked(); - - lv_obj_t *screen = lv_screen_active(); - ui_add_toggle_click(screen); - lv_obj_set_style_bg_color(screen, lv_color_hex(0x070a0d), 0); - - if(image_count == 0) ui_create_empty_image_browser_locked(screen); - else{ - image_tileview = lv_tileview_create(screen); - lv_obj_set_size(image_tileview, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_bg_color(image_tileview, lv_color_hex(0x070a0d), 0); - lv_obj_set_style_border_width(image_tileview, 0, 0); - lv_obj_set_style_pad_all(image_tileview, 0, 0); - lv_obj_set_scrollbar_mode(image_tileview, LV_SCROLLBAR_MODE_OFF); - lv_obj_add_event_cb(image_tileview, ui_image_browser_value_changed_event_cb, LV_EVENT_VALUE_CHANGED, NULL); - image_browser_image_count = image_count; - - ui_create_image_browser_tiles_locked(image_count); - ui_image_browser_set_index_locked(0, LV_ANIM_OFF); - ui_start_image_slideshow_locked(); - } - - current_view = UI_VIEW_IMAGE_BROWSER; -} - void create_image_browser_ui(void){ ESP_LOGI(TAG, "Creating image browser UI"); ui_log_append("Creating image browser UI"); lvgl_port_lock(0); - ui_create_image_browser_ui_locked(); + ui_build_image_browser_view_locked(); lvgl_port_unlock(); - ESP_LOGI(TAG, "Image browser UI created with %u images", (unsigned int)image_browser_image_count); - ui_log_appendf("Image browser UI created with %u images", (unsigned int)image_browser_image_count); + const unsigned int image_count = (unsigned int)ui_image_browser_get_image_count(); + ESP_LOGI(TAG, "Image browser UI created with %u images", image_count); + ui_log_appendf("Image browser UI created with %u images", image_count); } void ui_switch_view_to(ui_view_t view){ @@ -451,12 +79,12 @@ void ui_switch_view_to(ui_view_t view){ switch(view){ case UI_VIEW_LOG: lvgl_port_lock(0); - ui_create_log_ui_locked(); + ui_build_log_view_locked(); lvgl_port_unlock(); break; case UI_VIEW_IMAGE_BROWSER: lvgl_port_lock(0); - ui_create_image_browser_ui_locked(); + ui_build_image_browser_view_locked(); lvgl_port_unlock(); break; default: diff --git a/main/lcd/ui_image_browser.c b/main/lcd/ui_image_browser.c new file mode 100644 index 0000000..ec3feba --- /dev/null +++ b/main/lcd/ui_image_browser.c @@ -0,0 +1,309 @@ +#include +#include +#include +#include +#include +#include + +#include "lvgl.h" +#include "esp_log.h" + +#include "lcd/ui_image_browser.h" +#include "lcd/ui_internal.h" +#include "lcd/ui_log.h" +#include "sd/sd.h" + +#define UI_IMAGE_PATH_PREFIX "A:/sdcard/" +#define UI_IMAGE_PATH_MAX_LENGTH 160 +#define UI_IMAGE_INFO_BAR_HEIGHT 42 +#define UI_IMAGE_INFO_BAR_BOTTOM_OFFSET 10 +#define UI_IMAGE_PROBE_HEADER_LENGTH 16 +#define UI_SLIDESHOW_PERIOD_MS 3000 + +static lv_obj_t *image_tileview = NULL; +static lv_timer_t *image_slideshow_timer = NULL; +static size_t image_browser_image_count = 0; +static size_t image_browser_current_index = 0; +static const char *TAG = "ui"; + +static void ui_image_browser_set_index(size_t image_index, lv_anim_enable_t anim_en){ + if(image_tileview == NULL || image_browser_image_count == 0) return; + + image_browser_current_index = image_index % image_browser_image_count; + lv_tileview_set_tile_by_index(image_tileview, 0, (uint32_t)image_browser_current_index, anim_en); +} + +static void ui_image_slideshow_timer_cb(lv_timer_t *timer){ + (void)timer; + if(ui_current_view() != UI_VIEW_IMAGE_BROWSER) return; + ui_image_browser_set_index(image_browser_current_index + 1, LV_ANIM_ON); +} + +static void ui_start_image_slideshow(void){ + if(image_slideshow_timer != NULL || image_browser_image_count <= 1) return; + image_slideshow_timer = lv_timer_create(ui_image_slideshow_timer_cb, UI_SLIDESHOW_PERIOD_MS, NULL); +} + +static void ui_image_browser_value_changed_event_cb(lv_event_t *event){ + if(lv_event_get_code(event) != LV_EVENT_VALUE_CHANGED) return; + + lv_obj_t *tileview = lv_event_get_current_target(event); + lv_obj_t *active_tile = lv_tileview_get_tile_active(tileview); + if(active_tile == NULL) return; + + image_browser_current_index = (size_t)(uintptr_t)lv_obj_get_user_data(active_tile); +} + +static bool ui_char_equals_ignore_case(char left_char, char right_char){ + if(left_char >= 'A' && left_char <= 'Z') left_char = (char)(left_char + ('a' - 'A')); + if(right_char >= 'A' && right_char <= 'Z') right_char = (char)(right_char + ('a' - 'A')); + return left_char == right_char; +} + +static bool ui_has_extension(const char *filename, const char *extension){ + if(filename == NULL || extension == NULL) return false; + + const size_t filename_length = strlen(filename); + const size_t extension_length = strlen(extension); + if(filename_length < extension_length) return false; + + const char *filename_extension = filename + filename_length - extension_length; + for(size_t char_index = 0; char_index < extension_length; char_index++){ + if(!ui_char_equals_ignore_case(filename_extension[char_index], extension[char_index])) return false; + } + + return true; +} + +static void ui_lowercase_path_extension(char *path){ + char *extension = strrchr(path, '.'); + if(extension == NULL) return; + + for(char *current_char = extension + 1; *current_char != '\0'; current_char++){ + if(*current_char >= 'A' && *current_char <= 'Z') *current_char = (char)(*current_char + ('a' - 'A')); + } +} + +static bool ui_is_image_file(const char *filename){ + return ui_has_extension(filename, ".bmp") || + ui_has_extension(filename, ".jpg") || + ui_has_extension(filename, ".jpeg") || + ui_has_extension(filename, ".png"); +} + +static const char *ui_detect_image_signature(const uint8_t *header, size_t header_length){ + if(header_length >= 2 && header[0] == 0xff && header[1] == 0xd8) return "jpeg"; + if(header_length >= 8 && header[0] == 0x89 && header[1] == 'P' && header[2] == 'N' && header[3] == 'G') return "png"; + if(header_length >= 2 && header[0] == 'B' && header[1] == 'M') return "bmp"; + return "unknown"; +} + +static long ui_get_open_file_size(FILE *file){ + if(file == NULL) return -1; + + const long current_position = ftell(file); + if(current_position < 0) return -1; + if(fseek(file, 0, SEEK_END) != 0) return -1; + + const long file_size = ftell(file); + fseek(file, current_position, SEEK_SET); + return file_size; +} + +static const char *ui_probe_image_load_failure(const char *filename, const char *image_source){ + if(filename == NULL || image_source == NULL) return "Invalid image path"; + + errno = 0; + FILE *file = sd_open_read(filename); + if(file == NULL){ + const int open_errno = errno; + ESP_LOGW(TAG, "Image file not readable via sd_open_read: file=%s source=%s errno=%d", filename, image_source, open_errno); + ui_log_appendf("Image read failed: %s errno=%d", filename, open_errno); + return "File not readable"; + } + + uint8_t header_bytes[UI_IMAGE_PROBE_HEADER_LENGTH] = {0}; + const size_t header_length = fread(header_bytes, 1, sizeof(header_bytes), file); + const long file_size = ui_get_open_file_size(file); + sd_close_file(file); + + const char *detected_format = ui_detect_image_signature(header_bytes, header_length); + ESP_LOGW(TAG, + "Image probe: file=%s source=%s size=%ld header=%02x %02x %02x %02x %02x %02x %02x %02x detected=%s", + filename, + image_source, + file_size, + (unsigned int)header_bytes[0], + (unsigned int)header_bytes[1], + (unsigned int)header_bytes[2], + (unsigned int)header_bytes[3], + (unsigned int)header_bytes[4], + (unsigned int)header_bytes[5], + (unsigned int)header_bytes[6], + (unsigned int)header_bytes[7], + detected_format); + ui_log_appendf("Image probe: %s size=%ld detected=%s", filename, file_size, detected_format); + + lv_image_header_t image_header; + const lv_result_t decoder_result = lv_image_decoder_get_info(image_source, &image_header); + if(decoder_result != LV_RESULT_OK){ + ESP_LOGW(TAG, "LVGL decoder rejected image: source=%s result=%d", image_source, (int)decoder_result); + ui_log_appendf("Decoder rejected: %s result=%d", filename, (int)decoder_result); + return "Decoder rejected image"; + } + + ESP_LOGW(TAG, + "LVGL decoder accepted image but widget has no size: source=%s width=%u height=%u cf=%u stride=%u flags=0x%x", + image_source, + (unsigned int)image_header.w, + (unsigned int)image_header.h, + (unsigned int)image_header.cf, + (unsigned int)image_header.stride, + (unsigned int)image_header.flags); + ui_log_appendf("Decoder info OK: %s %ux%u", filename, (unsigned int)image_header.w, (unsigned int)image_header.h); + return "Decoder info OK"; +} + +static size_t ui_count_sd_images(void){ + const size_t file_count = sd_get_file_count(); + size_t image_count = 0; + + for(size_t file_index = 0; file_index < file_count; file_index++){ + const char *filename = sd_get_next_file(); + if(ui_is_image_file(filename)) image_count++; + } + + return image_count; +} + +static void ui_create_empty_image_browser(lv_obj_t *screen){ + lv_obj_t *label = lv_label_create(screen); + ui_attach_toggle(label); + lv_label_set_text(label, "No images found on SD card"); + lv_obj_set_style_text_color(label, lv_color_hex(0xdde7ef), 0); + lv_obj_center(label); +} + +static lv_dir_t ui_get_tile_directions(size_t image_index, size_t image_count){ + if(image_count <= 1) return LV_DIR_NONE; + if(image_index == 0) return LV_DIR_BOTTOM; + if(image_index + 1 == image_count) return LV_DIR_TOP; + return (lv_dir_t)(LV_DIR_TOP | LV_DIR_BOTTOM); +} + +static void ui_create_image_tile(lv_obj_t *tile, const char *filename, size_t image_index, size_t image_count){ + ui_attach_toggle(tile); + lv_obj_set_style_bg_color(tile, lv_color_hex(0x070a0d), 0); + lv_obj_set_style_pad_all(tile, 0, 0); + lv_obj_remove_flag(tile, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *image_frame = lv_obj_create(tile); + ui_attach_toggle(image_frame); + lv_obj_set_size(image_frame, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(image_frame, lv_color_black(), 0); + lv_obj_set_style_border_width(image_frame, 0, 0); + lv_obj_set_style_radius(image_frame, 0, 0); + lv_obj_set_style_pad_all(image_frame, 0, 0); + lv_obj_remove_flag(image_frame, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_center(image_frame); + + lv_obj_t *image = lv_image_create(image_frame); + ui_attach_toggle(image); + lv_obj_set_size(image, LV_PCT(100), LV_PCT(100)); + lv_image_set_inner_align(image, LV_IMAGE_ALIGN_CONTAIN); + + char image_source[UI_IMAGE_PATH_MAX_LENGTH]; + snprintf(image_source, sizeof(image_source), "%s%s", UI_IMAGE_PATH_PREFIX, filename); + ui_lowercase_path_extension(image_source); + lv_image_set_src(image, image_source); + lv_obj_center(image); + + lv_obj_t *info_bar = lv_obj_create(tile); + ui_attach_toggle(info_bar); + lv_obj_set_size(info_bar, LV_PCT(100), UI_IMAGE_INFO_BAR_HEIGHT); + lv_obj_align(info_bar, LV_ALIGN_BOTTOM_MID, 0, -UI_IMAGE_INFO_BAR_BOTTOM_OFFSET); + lv_obj_set_style_bg_color(info_bar, lv_color_hex(0x101418), 0); + lv_obj_set_style_bg_opa(info_bar, LV_OPA_80, 0); + lv_obj_set_style_border_width(info_bar, 0, 0); + lv_obj_set_style_radius(info_bar, 0, 0); + lv_obj_set_style_pad_hor(info_bar, 14, 0); + lv_obj_set_style_pad_ver(info_bar, 0, 0); + lv_obj_remove_flag(info_bar, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *caption = lv_label_create(info_bar); + ui_attach_toggle(caption); + lv_label_set_long_mode(caption, LV_LABEL_LONG_DOT); + lv_obj_set_width(caption, LV_PCT(100)); + lv_obj_set_style_text_color(caption, lv_color_hex(0xdde7ef), 0); + lv_label_set_text_fmt(caption, "%u/%u %s", (unsigned int)(image_index + 1), (unsigned int)image_count, filename); + lv_obj_center(caption); + + if(lv_image_get_src_width(image) <= 0 || lv_image_get_src_height(image) <= 0){ + const char *load_reason = ui_probe_image_load_failure(filename, image_source); + ESP_LOGW(TAG, "Image cannot be loaded by LVGL: %s reason=%s", image_source, load_reason); + ui_log_appendf("Image load failed: %s (%s)", filename, load_reason); + lv_obj_t *error_label = lv_label_create(tile); + ui_attach_toggle(error_label); + lv_label_set_text_fmt(error_label, "Image cannot be loaded\n%s\n%s", filename, load_reason); + lv_obj_set_style_text_color(error_label, lv_color_hex(0xffd166), 0); + lv_obj_set_style_text_align(error_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_width(error_label, LV_PCT(86)); + lv_obj_align(error_label, LV_ALIGN_CENTER, 0, -18); + } +} + +static void ui_create_image_browser_tiles(size_t image_count){ + const size_t file_count = sd_get_file_count(); + size_t image_index = 0; + + for(size_t file_index = 0; file_index < file_count && image_index < image_count; file_index++){ + const char *filename = sd_get_next_file(); + if(!ui_is_image_file(filename)) continue; + + lv_obj_t *tile = lv_tileview_add_tile(image_tileview, 0, (uint32_t)image_index, ui_get_tile_directions(image_index, image_count)); + lv_obj_set_user_data(tile, (void *)(uintptr_t)image_index); + ui_create_image_tile(tile, filename, image_index, image_count); + image_index++; + } +} + +void ui_image_browser_build_view(lv_obj_t *screen){ + const size_t image_count = ui_count_sd_images(); + + ui_attach_toggle(screen); + lv_obj_set_style_bg_color(screen, lv_color_hex(0x070a0d), 0); + + if(image_count == 0){ + image_browser_image_count = 0; + ui_create_empty_image_browser(screen); + return; + } + + image_tileview = lv_tileview_create(screen); + lv_obj_set_size(image_tileview, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(image_tileview, lv_color_hex(0x070a0d), 0); + lv_obj_set_style_border_width(image_tileview, 0, 0); + lv_obj_set_style_pad_all(image_tileview, 0, 0); + lv_obj_set_scrollbar_mode(image_tileview, LV_SCROLLBAR_MODE_OFF); + lv_obj_add_event_cb(image_tileview, ui_image_browser_value_changed_event_cb, LV_EVENT_VALUE_CHANGED, NULL); + image_browser_image_count = image_count; + + ui_create_image_browser_tiles(image_count); + ui_image_browser_set_index(0, LV_ANIM_OFF); + ui_start_image_slideshow(); +} + +void ui_image_browser_reset(void){ + if(image_slideshow_timer != NULL){ + lv_timer_delete(image_slideshow_timer); + image_slideshow_timer = NULL; + } + + image_tileview = NULL; + image_browser_image_count = 0; + image_browser_current_index = 0; +} + +size_t ui_image_browser_get_image_count(void){ + return image_browser_image_count; +} diff --git a/main/lcd/ui_log.c b/main/lcd/ui_log.c new file mode 100644 index 0000000..4b42187 --- /dev/null +++ b/main/lcd/ui_log.c @@ -0,0 +1,114 @@ +#include +#include +#include +#include + +#include "lvgl.h" +#include "esp_lvgl_port.h" + +#include "lcd/ui_log.h" +#include "lcd/ui_internal.h" + +#define UI_LOG_MAX_LINES 41 +#define UI_LOG_LINE_MAX_LENGTH 160 + +static lv_obj_t *log_textarea = NULL; +static char log_lines[UI_LOG_MAX_LINES][UI_LOG_LINE_MAX_LENGTH]; +static size_t log_line_length = 0; +static size_t log_line_start = 0; + +static size_t ui_log_buffer_index(size_t line_offset){ + return (log_line_start + line_offset) % UI_LOG_MAX_LINES; +} + +// Stores text in the ring buffer, sets *out_evicted when the oldest line had to +// be dropped, and returns the stored (possibly truncated) line. +static const char *ui_log_store_line(const char *text, bool *out_evicted){ + size_t line_index; + bool evicted = false; + + if(log_line_length < UI_LOG_MAX_LINES){ + line_index = ui_log_buffer_index(log_line_length); + log_line_length++; + } + else{ + line_index = log_line_start; + log_line_start = ui_log_buffer_index(1); + evicted = true; + } + + snprintf(log_lines[line_index], sizeof(log_lines[line_index]), "%s", text); + *out_evicted = evicted; + return log_lines[line_index]; +} + +static void ui_log_append_line_locked(const char *line){ + lv_textarea_add_text(log_textarea, line); + lv_textarea_add_text(log_textarea, "\n"); + lv_textarea_set_cursor_pos(log_textarea, LV_TEXTAREA_CURSOR_LAST); +} + +static void ui_log_render_all_locked(void){ + lv_textarea_set_text(log_textarea, ""); + for(size_t line_offset = 0; line_offset < log_line_length; line_offset++){ + lv_textarea_add_text(log_textarea, log_lines[ui_log_buffer_index(line_offset)]); + lv_textarea_add_text(log_textarea, "\n"); + } + lv_textarea_set_cursor_pos(log_textarea, LV_TEXTAREA_CURSOR_LAST); +} + +void ui_log_append(const char *text){ + if(text == NULL) return; + + bool evicted = false; + const char *stored_line = ui_log_store_line(text, &evicted); + if(log_textarea == NULL) return; + + lvgl_port_lock(0); + // Appending a line is O(1); only a dropped oldest line forces a full rebuild. + if(evicted) ui_log_render_all_locked(); + else ui_log_append_line_locked(stored_line); + lvgl_port_unlock(); +} + +void ui_log_appendf(const char *format, ...){ + if(format == NULL) return; + + char line[UI_LOG_LINE_MAX_LENGTH]; + va_list args; + va_start(args, format); + vsnprintf(line, sizeof(line), format, args); + va_end(args); + + ui_log_append(line); +} + +void ui_log_detach(void){ + log_textarea = NULL; +} + +void ui_log_build_view(lv_obj_t *screen){ + ui_attach_toggle(screen); + lv_obj_set_style_bg_color(screen, lv_color_hex(0x101418), 0); + + lv_obj_t *title = lv_label_create(screen); + ui_attach_toggle(title); + lv_label_set_text(title, "SD Image Viewer by Timo Niemann"); + lv_obj_set_style_text_color(title, lv_color_white(), 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 14); + + log_textarea = lv_textarea_create(screen); + ui_attach_toggle(log_textarea); + lv_obj_set_size(log_textarea, 440, 730); + lv_obj_align(log_textarea, LV_ALIGN_TOP_LEFT, 20, 40); + lv_textarea_set_one_line(log_textarea, false); + lv_textarea_set_cursor_click_pos(log_textarea, false); + lv_textarea_set_text(log_textarea, ""); + lv_obj_set_style_bg_color(log_textarea, lv_color_hex(0x182028), 0); + lv_obj_set_style_text_color(log_textarea, lv_color_hex(0xdde7ef), 0); + lv_obj_set_style_border_color(log_textarea, lv_color_hex(0x4b6478), 0); + lv_obj_set_style_radius(log_textarea, 4, 0); + lv_obj_set_style_pad_all(log_textarea, 12, 0); + + ui_log_render_all_locked(); +}