a8af29068b
Context: Two issues on the Freenove gamebook: (1) long passages were clipped — the reading band was small and text overflowed; (2) the 5-way pad was decoded wrong, so navigation felt random, and on this unit the physical Up button is electrically dead (held = no voltage change). Approach: Give the reading text the whole screen height in a vertically scrollable container, split selection from scrolling, and remap the keys to the pad's MEASURED voltages. Changes: - display_ui: gamebook view reworked — compact title, a full-height scrollable reading container (the body wraps and scrolls, never clipped), and a one-line choice selector pinned at the bottom; new display_ui_gamebook_scroll() + reset-on-new-passage; show() gains a reset_scroll arg; body buffer 512 -> 1100. - gamebook.c: controls matched to the measured ladder (key2=LEFT, key4=DOWN, key5=RIGHT, key1=CLICK; key3 unused; Up dead): Left/Right scroll the text up/down, Down cycles the answer, Click validates; library nav cycles tiles without needing Up. Impact: Long passages are fully readable (they scroll), and all four working pad buttons do the right thing; nothing depends on the dead Up button. Hardware-calibrated against the real pad voltages.
1510 lines
63 KiB
C++
1510 lines
63 KiB
C++
// display_ui.cpp — ST7796 + LVGL status screen, Phase 2a.
|
||
//
|
||
// Panel / bus configuration faithfully replicates the original Freenove
|
||
// Arduino firmware (ui_freenove_allinone/src/drivers/display/display_hal_lgfx.cpp
|
||
// FreenoveLgfxDevice, lines ~132-208):
|
||
//
|
||
// - Panel: Panel_ST7796
|
||
// - Bus: Bus_SPI, SPI2_HOST (FSPI; FREENOVE_LCD_USE_HSPI == 0)
|
||
// - Freq: 80 MHz write / 20 MHz read
|
||
// - spi_mode 0, spi_3wire false, use_lock false, dma_channel 0 (CPU mode)
|
||
// - Pins: SCK=47, MOSI=21, MISO=-1, DC=45, CS=-1, RST=20, BL=2
|
||
// - Panel: memory/panel 320×480, offset_x/y 0, offset_rotation 0,
|
||
// dummy_read_pixel 8, dummy_read_bits 1, readable false (no MISO),
|
||
// invert true (TFT_INVERSION_ON), rgb_order true (not BGR),
|
||
// dlen_16bit false, bus_shared true
|
||
// - Rotation: 1 (landscape)
|
||
//
|
||
// LVGL (Phase 2a):
|
||
// LVGL 8.4 (same line as the original: platformio.ini:35 lvgl/lvgl@8.4.0),
|
||
// configured via the managed component's Kconfig (sdkconfig: depth 16,
|
||
// swap 0, mem 160 KB, Montserrat 24/28 — mirroring the original lv_conf.h).
|
||
// Flush callback is the canonical LovyanGFX↔LVGL8 glue (startWrite /
|
||
// setAddrWindow / writePixels rgb565 / endWrite), matching the effective
|
||
// behaviour of the original ui_manager_display.cpp glue (its DMA path is
|
||
// disabled at runtime in the original HAL, so the synchronous path is the
|
||
// faithful one). Draw buffer: width × 40 lines, single, INTERNAL ram —
|
||
// exactly like the original (buffer_cfg_.draw_in_psram = false, lines = 40).
|
||
// Tick: esp_timer periodic 2 ms → lv_tick_inc(2) (the original calls
|
||
// lv_tick_inc(elapsed) from its UI task; LV_TICK_CUSTOM is off both sides).
|
||
//
|
||
// BACKLIGHT (Phase 2a):
|
||
// LEDC PWM on LEDC_TIMER_1 / LEDC_CHANNEL_1 (low-speed), ~44.1 kHz like the
|
||
// original Light_PWM. LEDC_TIMER_0 / LEDC_CHANNEL_0 stay reserved for the
|
||
// camera XCLK (qr_puzzle). display_ui_set_brightness() exposes duty 0-100%.
|
||
//
|
||
// Thread model:
|
||
// ALL LVGL and LovyanGFX calls run exclusively on the display_task.
|
||
// display_ui_set_status() copies the status under a mutex and sets a dirty
|
||
// flag; the task folds it into LVGL labels within ~50 ms. lv_tick_inc is
|
||
// called from the esp_timer task (atomic counter increment — the standard
|
||
// espressif pattern).
|
||
|
||
#include "display_ui.h"
|
||
#include "board_pins_mediakit.h"
|
||
|
||
#include <string.h>
|
||
#include <stdio.h>
|
||
#include <math.h>
|
||
#include <dirent.h>
|
||
#include <sys/stat.h>
|
||
|
||
#include "driver/gpio.h"
|
||
#include "driver/ledc.h"
|
||
#include "esp_heap_caps.h"
|
||
#include "esp_log.h"
|
||
#include "esp_random.h"
|
||
#include "esp_timer.h"
|
||
#include "freertos/FreeRTOS.h"
|
||
#include "freertos/semphr.h"
|
||
#include "freertos/task.h"
|
||
|
||
#define LGFX_USE_V1
|
||
#include <LovyanGFX.hpp>
|
||
#include "lvgl.h"
|
||
|
||
// Original UI fonts (fonts/ — copied from ui_freenove_allinone/src/ui/fonts/).
|
||
LV_FONT_DECLARE(lv_font_orbitron_40);
|
||
LV_FONT_DECLARE(lv_font_ibmplexmono_18);
|
||
|
||
static const char *TAG = "display_ui";
|
||
|
||
// ─── LovyanGFX device (faithful port of FreenoveLgfxDevice) ──────────────────
|
||
|
||
class ZacusDisplay final : public lgfx::LGFX_Device {
|
||
public:
|
||
ZacusDisplay() {
|
||
// ── SPI bus ──────────────────────────────────────────────────────────
|
||
{
|
||
auto cfg = _bus_instance.config();
|
||
cfg.spi_host = SPI2_HOST; // FSPI (FREENOVE_LCD_USE_HSPI == 0)
|
||
cfg.spi_mode = 0;
|
||
cfg.freq_write = 80000000; // 80 MHz
|
||
cfg.freq_read = 20000000; // 20 MHz
|
||
cfg.spi_3wire = false;
|
||
cfg.use_lock = false;
|
||
// CPU mode — DMA out-link init can be unavailable on this
|
||
// board/runtime mix (same note as original firmware, line ~145).
|
||
cfg.dma_channel = 0;
|
||
cfg.pin_sclk = TFT_PIN_SCK;
|
||
cfg.pin_mosi = TFT_PIN_MOSI;
|
||
cfg.pin_miso = TFT_PIN_MISO; // -1 (not wired)
|
||
cfg.pin_dc = TFT_PIN_DC;
|
||
_bus_instance.config(cfg);
|
||
_panel_instance.setBus(&_bus_instance);
|
||
}
|
||
|
||
// ── Panel ─────────────────────────────────────────────────────────────
|
||
{
|
||
auto cfg = _panel_instance.config();
|
||
cfg.pin_cs = -1; // CS not wired
|
||
cfg.pin_rst = TFT_PIN_RST;
|
||
cfg.pin_busy = -1;
|
||
cfg.memory_width = 320;
|
||
cfg.memory_height = 480;
|
||
cfg.panel_width = 320;
|
||
cfg.panel_height = 480;
|
||
cfg.offset_x = 0;
|
||
cfg.offset_y = 0;
|
||
cfg.offset_rotation = 0;
|
||
cfg.dummy_read_pixel = 8;
|
||
cfg.dummy_read_bits = 1;
|
||
cfg.readable = false; // MISO == -1
|
||
cfg.invert = true; // TFT_INVERSION_ON in original
|
||
cfg.rgb_order = true; // not BGR
|
||
cfg.dlen_16bit = false;
|
||
cfg.bus_shared = true;
|
||
_panel_instance.config(cfg);
|
||
}
|
||
|
||
// Backlight handled externally (LEDC PWM, see file header).
|
||
|
||
setPanel(&_panel_instance);
|
||
}
|
||
|
||
private:
|
||
lgfx::Bus_SPI _bus_instance;
|
||
lgfx::Panel_ST7796 _panel_instance;
|
||
};
|
||
|
||
static ZacusDisplay s_lcd;
|
||
|
||
// ─── Status state ─────────────────────────────────────────────────────────────
|
||
|
||
static display_status_t s_status;
|
||
static SemaphoreHandle_t s_mutex = NULL;
|
||
static volatile bool s_dirty = false;
|
||
static bool s_running = false;
|
||
|
||
// ─── Palette (Amiga Workbench-ish) ────────────────────────────────────────────
|
||
static const uint32_t COL_BG = 0x0055AA; // Workbench blue
|
||
static const uint32_t COL_TITLEBAR = 0x0000AA; // darker blue
|
||
static const uint32_t COL_TITLE_TXT = 0xFFFFFF; // white
|
||
static const uint32_t COL_LABEL = 0xAAAAAA; // mid grey
|
||
static const uint32_t COL_VALUE = 0xFFFFFF; // white
|
||
static const uint32_t COL_CODE = 0xFF8800; // Workbench orange
|
||
|
||
// ─── LVGL plumbing ────────────────────────────────────────────────────────────
|
||
|
||
#define DUI_HOR_RES 480 // rotation 1 (landscape)
|
||
#define DUI_VER_RES 320
|
||
#define DUI_BUF_LINES 40 // like the original (buffer_cfg_.lines)
|
||
|
||
static lv_disp_draw_buf_t s_draw_buf;
|
||
static lv_color_t *s_buf1;
|
||
static lv_disp_drv_t s_disp_drv;
|
||
static esp_timer_handle_t s_tick_timer;
|
||
|
||
// Canonical LovyanGFX↔LVGL8 glue (synchronous — matches the original's
|
||
// effective path; its DMA branch is disabled at runtime).
|
||
static void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area,
|
||
lv_color_t *color_p) {
|
||
const uint32_t w = (uint32_t) (area->x2 - area->x1 + 1);
|
||
const uint32_t h = (uint32_t) (area->y2 - area->y1 + 1);
|
||
s_lcd.startWrite();
|
||
s_lcd.setAddrWindow(area->x1, area->y1, w, h);
|
||
s_lcd.writePixels((lgfx::rgb565_t *) &color_p->full, w * h);
|
||
s_lcd.endWrite();
|
||
lv_disp_flush_ready(drv);
|
||
}
|
||
|
||
static void lvgl_tick_cb(void *arg) {
|
||
(void) arg;
|
||
lv_tick_inc(2);
|
||
}
|
||
|
||
// ─── LVGL status screen objects (display task only) ───────────────────────────
|
||
|
||
typedef struct {
|
||
lv_obj_t *value; // value label of a row
|
||
} dui_row_t;
|
||
|
||
static lv_obj_t *s_scr_status; // status screen (no active step)
|
||
static lv_obj_t *s_lbl_title;
|
||
static dui_row_t s_row_ip, s_row_wake, s_row_step, s_row_armed, s_row_solved;
|
||
static lv_obj_t *s_lbl_code;
|
||
|
||
// Scene view (active step) — structure mirrors the original ui_manager scene
|
||
// frame: title (Orbitron), subtitle (IBM Plex Mono), symbol slot (the
|
||
// assembled code, pulse effect = SceneEffect::kPulse default).
|
||
static lv_obj_t *s_scr_scene;
|
||
static lv_obj_t *s_scene_title; // step id, Orbitron 40, top-center
|
||
static lv_obj_t *s_scene_subtitle; // armed puzzle, IBM Plex 18, bottom
|
||
static lv_obj_t *s_scene_code; // assembled code, Orbitron 40, center
|
||
|
||
// Camera viewfinder (QR aiming aid). The qr_puzzle scan task pushes
|
||
// grayscale QVGA frames via display_ui_camera_frame(); the display task
|
||
// converts to RGB565 into the canvas buffer at ~5 fps.
|
||
#define CAM_W 320
|
||
#define CAM_H 240
|
||
static lv_obj_t *s_scene_cam; // lv_canvas, PSRAM-backed
|
||
static lv_color_t *s_cam_canvas_buf; // CAM_W*CAM_H rgb565 (PSRAM)
|
||
static uint8_t *s_cam_gray; // incoming copy (PSRAM)
|
||
static SemaphoreHandle_t s_cam_mutex;
|
||
static volatile bool s_cam_fresh = false;
|
||
static bool s_cam_visible = false;
|
||
|
||
// ─── Amiga Workbench shell (port of ui_amiga_shell, same grid metrics) ───────
|
||
#define SHELL_GRID_COLS 4 // = AmigaUIShell::GRID_COLS
|
||
#define SHELL_ICON_SIZE 64 // = ICON_SIZE
|
||
#define SHELL_ICON_SPACING 16 // = ICON_SPACING
|
||
#define SHELL_GRID_X 16 // = GRID_START_X
|
||
#define SHELL_GRID_Y 48 // GRID_START_Y(32) + our taller title bar
|
||
#define SHELL_BUILTINS 5
|
||
#define SHELL_MAX_TILES 12
|
||
static lv_obj_t *s_scr_shell;
|
||
static lv_obj_t *s_shell_tiles[SHELL_MAX_TILES];
|
||
// Written by the buttons task, applied by the display task.
|
||
static volatile uint8_t s_shell_sel = 0;
|
||
static volatile bool s_shell_open = false;
|
||
// Accent colors from the original shell palette (ui_amiga_shell.cpp:280-287).
|
||
static const struct { const char *name; uint32_t color; }
|
||
kShellBuiltins[SHELL_BUILTINS] = {
|
||
{"Statut", 0x00FFFF}, // cyan
|
||
{"Scene", 0xFF8800}, // orange
|
||
{"Auto", 0x00FF88}, // green
|
||
{"Lumiere", 0xFFFF00}, // yellow
|
||
{"Fichiers", 0x88FF00}, // lime — opens the file browser
|
||
};
|
||
// Dynamic apps from /littlefs/apps/<id>/ (icon.png optional, step.txt = the
|
||
// step to arm on launch — handled by main via the pending-launch poll).
|
||
typedef struct { char id[32]; } shell_dyn_app_t;
|
||
static shell_dyn_app_t s_shell_dyn[SHELL_MAX_TILES - SHELL_BUILTINS];
|
||
static uint8_t s_shell_count = SHELL_BUILTINS; // builtins + dynamic
|
||
static bool s_shell_loaded = false; // lazy (littlefs mounts late)
|
||
|
||
// Pending app launch (buttons task → main poll).
|
||
static char s_pending_launch[32];
|
||
static volatile bool s_pending_launch_set = false;
|
||
|
||
// ─── File browser (original shell's drawer, master edition) ─────────────────
|
||
#define BROWSER_MAX_ENTRIES 24
|
||
static lv_obj_t *s_scr_browser;
|
||
static lv_obj_t *s_browser_list; // plain container, rebuilt on reload
|
||
static lv_obj_t *s_browser_title;
|
||
static char s_browser_path[96] = "/littlefs";
|
||
static char s_browser_items[BROWSER_MAX_ENTRIES][40];
|
||
static bool s_browser_isdir[BROWSER_MAX_ENTRIES];
|
||
static uint8_t s_browser_count = 0;
|
||
static volatile uint8_t s_browser_sel = 0;
|
||
static volatile bool s_browser_open = false;
|
||
|
||
// ── Gamebook view ("livre dont vous êtes le héros") ─────────────────────────
|
||
static lv_obj_t *s_scr_gamebook;
|
||
static lv_obj_t *s_gb_title; // page title, top
|
||
static lv_obj_t *s_gb_body; // full passage text, wrapped
|
||
static lv_obj_t *s_gb_menu; // choice lines, bottom
|
||
static volatile bool s_gamebook_open = false;
|
||
static lv_obj_t *s_gb_body_cont; // scrollable container holding s_gb_body
|
||
static char s_gb_title_buf[64];
|
||
static char s_gb_body_buf[1100];
|
||
static char s_gb_menu_buf[160];
|
||
static volatile int s_gb_scroll_req = 0; // pending scroll: +pages down / -pages up
|
||
static volatile bool s_gb_scroll_home = false; // reset scroll to top on new passage
|
||
|
||
// ── Gamebook library (tile grid) ────────────────────────────────────────────
|
||
#define LIB_TILES 6
|
||
static lv_obj_t *s_scr_library;
|
||
static lv_obj_t *s_lib_tile[LIB_TILES];
|
||
static lv_obj_t *s_lib_label[LIB_TILES];
|
||
static volatile bool s_library_open = false;
|
||
static char s_lib_title_buf[LIB_TILES][48];
|
||
static int s_lib_count_buf = 0;
|
||
static int s_lib_sel_buf = 0;
|
||
|
||
static volatile bool s_browser_reload = false;
|
||
|
||
// ─── Intro — faithful port of the original cracktro ──────────────────────────
|
||
// Phase A from ui_manager_intro.cpp: 3-layer parallax starfield in Q8.8
|
||
// fixed point (layer split 50/30/20%, speeds {54,116,198} px/s, sizes 1-3 px,
|
||
// opacities 30/60/100%, 1-in-8 twinkle), logo + drop shadow (offset 1,23),
|
||
// bottom sine-wave scrolltext, copper bars. Phases B/C/D are the original 3D
|
||
// FX (rotozoom, dot sphere, ray corridor) ported in intro_fx3d.cpp, rendered
|
||
// into a full-screen LVGL canvas behind the logo + scroller. If the canvas
|
||
// PSRAM allocation fails, the intro gracefully stays on phase A throughout.
|
||
#include "intro_fx3d.h"
|
||
#define INTRO_STARS 48
|
||
#define INTRO_BARS 4
|
||
#define INTRO_PHASE_A_MS 7000
|
||
#define INTRO_FX_MS 3000
|
||
#define INTRO_DURATION_MS (INTRO_PHASE_A_MS + 3 * INTRO_FX_MS)
|
||
typedef struct { int32_t x_q8, y_q8; uint8_t layer; int16_t speed; } intro_star_t;
|
||
static lv_obj_t *s_scr_intro;
|
||
static lv_obj_t *s_intro_logo, *s_intro_logo_shadow;
|
||
static lv_obj_t *s_intro_scroll;
|
||
static lv_obj_t *s_star_objs[INTRO_STARS];
|
||
static intro_star_t s_stars[INTRO_STARS];
|
||
static lv_obj_t *s_intro_bars[INTRO_BARS];
|
||
static int32_t s_scroll_x_q8; // scrolltext x in Q8.8
|
||
static uint32_t s_intro_t_ms; // elapsed intro time
|
||
static volatile bool s_intro_done = false;
|
||
static lv_obj_t *s_fx_canvas; // phases B/C/D render target
|
||
static lv_color_t *s_fx_canvas_buf; // 480x320 RGB565, PSRAM
|
||
static bool s_fx_tried, s_fx_ok; // lazy one-shot setup at phase B
|
||
static fx3d_mode_t s_fx_order[3]; // 3 distinct FX drawn per boot
|
||
// Original kIntro defaults: scroll 90 px/s, sine amp 14 px, period 120 px.
|
||
#define INTRO_SCROLL_PXS 90
|
||
#define INTRO_SINE_AMP 14
|
||
#define INTRO_SINE_PERIOD 120.0f
|
||
|
||
static lv_obj_t *make_row(lv_obj_t *parent, int y, const char *label_txt,
|
||
dui_row_t *out) {
|
||
lv_obj_t *lbl = lv_label_create(parent);
|
||
lv_obj_set_style_text_color(lbl, lv_color_hex(COL_LABEL), 0);
|
||
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0);
|
||
lv_label_set_text(lbl, label_txt);
|
||
lv_obj_set_pos(lbl, 8, y);
|
||
|
||
lv_obj_t *val = lv_label_create(parent);
|
||
lv_obj_set_style_text_color(val, lv_color_hex(COL_VALUE), 0);
|
||
lv_obj_set_style_text_font(val, &lv_font_montserrat_14, 0);
|
||
lv_label_set_text(val, "...");
|
||
lv_obj_set_pos(val, 160, y);
|
||
lv_label_set_long_mode(val, LV_LABEL_LONG_DOT);
|
||
lv_obj_set_width(val, DUI_HOR_RES - 168);
|
||
|
||
out->value = val;
|
||
return val;
|
||
}
|
||
|
||
// ─── Scene effects (ports of the original ui_manager SceneEffect set) ────────
|
||
|
||
// Pulse — SceneEffect::kPulse equivalent (opacity breathe on the symbol/code).
|
||
static void pulse_anim_cb(void *obj, int32_t v) {
|
||
lv_obj_set_style_opa((lv_obj_t *) obj, (lv_opa_t) v, 0);
|
||
}
|
||
|
||
// Glitch — jitter + flicker on the title (text-artifact evocation of the
|
||
// original glitch overlay; the full pixel-shred shader is a later phase).
|
||
static lv_timer_t *s_glitch_timer;
|
||
static void glitch_timer_cb(lv_timer_t *t) {
|
||
(void) t;
|
||
const int dx = (int) (esp_random() % 7) - 3;
|
||
lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, dx, 24);
|
||
lv_obj_set_style_opa(s_scene_title,
|
||
(esp_random() % 8) ? LV_OPA_COVER : LV_OPA_60, 0);
|
||
}
|
||
|
||
// Gyrophare — rotating beacon ring around the scene.
|
||
static lv_obj_t *s_gyro_arc;
|
||
static void gyro_anim_cb(void *obj, int32_t v) {
|
||
lv_arc_set_rotation((lv_obj_t *) obj, (uint16_t) v);
|
||
}
|
||
|
||
static uint8_t s_fx_applied = 0xFF; // force first apply
|
||
|
||
static void start_pulse(void) {
|
||
lv_anim_t a;
|
||
lv_anim_init(&a);
|
||
lv_anim_set_var(&a, s_scene_code);
|
||
lv_anim_set_exec_cb(&a, pulse_anim_cb);
|
||
lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_50);
|
||
lv_anim_set_time(&a, 600);
|
||
lv_anim_set_playback_time(&a, 600);
|
||
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
|
||
lv_anim_start(&a);
|
||
}
|
||
|
||
static void apply_effect(uint8_t fx) {
|
||
if (fx == s_fx_applied) return;
|
||
s_fx_applied = fx;
|
||
|
||
// Stop everything, restore neutral state.
|
||
lv_anim_del(s_scene_code, pulse_anim_cb);
|
||
lv_obj_set_style_opa(s_scene_code, LV_OPA_COVER, 0);
|
||
if (s_glitch_timer) lv_timer_pause(s_glitch_timer);
|
||
lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, 0, 24);
|
||
lv_obj_set_style_opa(s_scene_title, LV_OPA_COVER, 0);
|
||
if (s_gyro_arc) {
|
||
lv_anim_del(s_gyro_arc, gyro_anim_cb);
|
||
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
|
||
switch (fx) {
|
||
case 0: // pulse (default)
|
||
start_pulse();
|
||
break;
|
||
case 1: // glitch
|
||
if (s_glitch_timer) lv_timer_resume(s_glitch_timer);
|
||
break;
|
||
case 2: // gyro
|
||
if (s_gyro_arc) {
|
||
lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
|
||
lv_anim_t a;
|
||
lv_anim_init(&a);
|
||
lv_anim_set_var(&a, s_gyro_arc);
|
||
lv_anim_set_exec_cb(&a, gyro_anim_cb);
|
||
lv_anim_set_values(&a, 0, 360);
|
||
lv_anim_set_time(&a, 1200);
|
||
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
|
||
lv_anim_start(&a);
|
||
}
|
||
break;
|
||
default: // none
|
||
break;
|
||
}
|
||
}
|
||
|
||
static void build_scene_screen(void) {
|
||
s_scr_scene = lv_obj_create(NULL);
|
||
lv_obj_set_style_bg_color(s_scr_scene, lv_color_hex(0x000000), 0);
|
||
lv_obj_set_style_bg_opa(s_scr_scene, LV_OPA_COVER, 0);
|
||
|
||
// Viewfinder canvas (hidden unless the QR puzzle is armed).
|
||
s_cam_canvas_buf = (lv_color_t *) heap_caps_malloc(
|
||
(size_t) CAM_W * CAM_H * sizeof(lv_color_t), MALLOC_CAP_SPIRAM);
|
||
s_cam_gray = (uint8_t *) heap_caps_malloc(
|
||
(size_t) CAM_W * CAM_H, MALLOC_CAP_SPIRAM);
|
||
if (s_cam_canvas_buf && s_cam_gray) {
|
||
s_scene_cam = lv_canvas_create(s_scr_scene);
|
||
lv_canvas_set_buffer(s_scene_cam, s_cam_canvas_buf,
|
||
CAM_W, CAM_H, LV_IMG_CF_TRUE_COLOR);
|
||
lv_canvas_fill_bg(s_scene_cam, lv_color_hex(0x101010), LV_OPA_COVER);
|
||
lv_obj_align(s_scene_cam, LV_ALIGN_CENTER, 0, 8);
|
||
lv_obj_add_flag(s_scene_cam, LV_OBJ_FLAG_HIDDEN);
|
||
} else {
|
||
ESP_LOGW(TAG, "viewfinder buffers alloc failed — preview disabled");
|
||
}
|
||
|
||
s_scene_title = lv_label_create(s_scr_scene);
|
||
lv_obj_set_style_text_color(s_scene_title, lv_color_hex(COL_VALUE), 0);
|
||
lv_obj_set_style_text_font(s_scene_title, &lv_font_orbitron_40, 0);
|
||
lv_label_set_text(s_scene_title, "");
|
||
lv_label_set_long_mode(s_scene_title, LV_LABEL_LONG_SCROLL_CIRCULAR);
|
||
lv_obj_set_width(s_scene_title, DUI_HOR_RES - 24);
|
||
lv_obj_set_style_text_align(s_scene_title, LV_TEXT_ALIGN_CENTER, 0);
|
||
lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, 0, 24);
|
||
|
||
s_scene_code = lv_label_create(s_scr_scene);
|
||
lv_obj_set_style_text_color(s_scene_code, lv_color_hex(COL_CODE), 0);
|
||
lv_obj_set_style_text_font(s_scene_code, &lv_font_orbitron_40, 0);
|
||
lv_label_set_text(s_scene_code, "");
|
||
lv_obj_align(s_scene_code, LV_ALIGN_CENTER, 0, 16);
|
||
|
||
s_scene_subtitle = lv_label_create(s_scr_scene);
|
||
lv_obj_set_style_text_color(s_scene_subtitle, lv_color_hex(COL_LABEL), 0);
|
||
lv_obj_set_style_text_font(s_scene_subtitle, &lv_font_ibmplexmono_18, 0);
|
||
lv_label_set_text(s_scene_subtitle, "");
|
||
lv_obj_align(s_scene_subtitle, LV_ALIGN_BOTTOM_LEFT, 8, -10);
|
||
|
||
// Effect plumbing: glitch timer (paused until selected) + gyro ring
|
||
// (hidden until selected). Pulse starts via apply_effect on first status.
|
||
s_glitch_timer = lv_timer_create(glitch_timer_cb, 120, NULL);
|
||
lv_timer_pause(s_glitch_timer);
|
||
|
||
s_gyro_arc = lv_arc_create(s_scr_scene);
|
||
lv_obj_set_size(s_gyro_arc, 300, 300);
|
||
lv_obj_center(s_gyro_arc);
|
||
lv_arc_set_bg_angles(s_gyro_arc, 0, 360);
|
||
lv_arc_set_angles(s_gyro_arc, 0, 60); // 60° beacon segment
|
||
lv_obj_remove_style(s_gyro_arc, NULL, LV_PART_KNOB);
|
||
lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_CLICKABLE);
|
||
lv_obj_set_style_arc_opa(s_gyro_arc, LV_OPA_TRANSP, LV_PART_MAIN);
|
||
lv_obj_set_style_arc_color(s_gyro_arc, lv_color_hex(0xFF3300),
|
||
LV_PART_INDICATOR);
|
||
lv_obj_set_style_arc_width(s_gyro_arc, 8, LV_PART_INDICATOR);
|
||
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
|
||
static void build_shell_screen(void) {
|
||
s_scr_shell = lv_obj_create(NULL);
|
||
lv_obj_set_style_bg_color(s_scr_shell, lv_color_hex(COL_BG), 0);
|
||
lv_obj_set_style_bg_opa(s_scr_shell, LV_OPA_COVER, 0);
|
||
|
||
lv_obj_t *bar = lv_obj_create(s_scr_shell);
|
||
lv_obj_remove_style_all(bar);
|
||
lv_obj_set_size(bar, DUI_HOR_RES, 36);
|
||
lv_obj_set_style_bg_color(bar, lv_color_hex(COL_TITLEBAR), 0);
|
||
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0);
|
||
lv_obj_t *t = lv_label_create(bar);
|
||
lv_obj_set_style_text_color(t, lv_color_hex(COL_TITLE_TXT), 0);
|
||
lv_obj_set_style_text_font(t, &lv_font_montserrat_24, 0);
|
||
lv_label_set_text(t, "Zacus Workbench");
|
||
lv_obj_align(t, LV_ALIGN_LEFT_MID, 8, 0);
|
||
// Tiles are created lazily by shell_populate() — littlefs mounts after
|
||
// display_ui_init, so dynamic apps can't be scanned here.
|
||
}
|
||
|
||
static lv_obj_t *shell_make_tile(int index, const char *name, uint32_t color,
|
||
const char *icon_path) {
|
||
const int col = index % SHELL_GRID_COLS;
|
||
const int row = index / SHELL_GRID_COLS;
|
||
lv_obj_t *tile = lv_obj_create(s_scr_shell);
|
||
lv_obj_remove_style_all(tile);
|
||
lv_obj_set_size(tile, SHELL_ICON_SIZE, SHELL_ICON_SIZE);
|
||
lv_obj_set_pos(tile,
|
||
SHELL_GRID_X + col * (SHELL_ICON_SIZE + SHELL_ICON_SPACING),
|
||
SHELL_GRID_Y + row * (SHELL_ICON_SIZE + SHELL_ICON_SPACING));
|
||
lv_obj_set_style_bg_color(tile, lv_color_hex(color), 0);
|
||
lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0);
|
||
lv_obj_set_style_radius(tile, 4, 0); // like the original
|
||
lv_obj_set_style_border_color(tile, lv_color_white(), 0);
|
||
lv_obj_set_style_border_width(tile, 1, 0);
|
||
|
||
if (icon_path) {
|
||
// PNG icon via LVGL FS drive 'L' (→ /littlefs), like the original's
|
||
// "L:" + icon.png path.
|
||
lv_obj_t *img = lv_img_create(tile);
|
||
lv_img_set_src(img, icon_path);
|
||
lv_obj_align(img, LV_ALIGN_TOP_MID, 0, 2);
|
||
}
|
||
|
||
lv_obj_t *lbl = lv_label_create(tile);
|
||
lv_obj_set_style_text_color(lbl, lv_color_hex(0x000000), 0);
|
||
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0);
|
||
lv_label_set_text(lbl, name);
|
||
lv_obj_align(lbl, LV_ALIGN_BOTTOM_MID, 0, -4);
|
||
return tile;
|
||
}
|
||
|
||
// Lazy tile creation: builtins + /littlefs/apps/<id>/ (display task only).
|
||
static void shell_populate(void) {
|
||
if (s_shell_loaded) return;
|
||
s_shell_loaded = true;
|
||
|
||
for (int i = 0; i < SHELL_BUILTINS; i++) {
|
||
s_shell_tiles[i] = shell_make_tile(i, kShellBuiltins[i].name,
|
||
kShellBuiltins[i].color, NULL);
|
||
}
|
||
s_shell_count = SHELL_BUILTINS;
|
||
|
||
DIR *dir = opendir("/littlefs/apps");
|
||
if (!dir) return; // no apps dir — builtins only
|
||
struct dirent *ent;
|
||
while ((ent = readdir(dir)) != NULL &&
|
||
s_shell_count < SHELL_MAX_TILES) {
|
||
if (ent->d_type != DT_DIR || ent->d_name[0] == '.') continue;
|
||
shell_dyn_app_t *app = &s_shell_dyn[s_shell_count - SHELL_BUILTINS];
|
||
strncpy(app->id, ent->d_name, sizeof(app->id) - 1);
|
||
app->id[sizeof(app->id) - 1] = '\0';
|
||
|
||
// Optional PNG icon (LVGL path with the L: drive).
|
||
char fs_path[96], lv_path[96];
|
||
snprintf(fs_path, sizeof(fs_path), "/littlefs/apps/%s/icon.png", app->id);
|
||
struct stat st;
|
||
const bool has_icon = (stat(fs_path, &st) == 0);
|
||
snprintf(lv_path, sizeof(lv_path), "L:apps/%s/icon.png", app->id);
|
||
|
||
s_shell_tiles[s_shell_count] = shell_make_tile(
|
||
s_shell_count, app->id, 0x335577,
|
||
has_icon ? lv_path : NULL);
|
||
s_shell_count++;
|
||
}
|
||
closedir(dir);
|
||
ESP_LOGI(TAG, "shell: %d tiles (%d apps from /littlefs/apps)",
|
||
s_shell_count, s_shell_count - SHELL_BUILTINS);
|
||
}
|
||
|
||
// Highlight the selected tile (display task only).
|
||
static void apply_shell_selection(void) {
|
||
for (int i = 0; i < s_shell_count; i++) {
|
||
if (s_shell_tiles[i])
|
||
lv_obj_set_style_border_width(s_shell_tiles[i],
|
||
(i == s_shell_sel) ? 4 : 1, 0);
|
||
}
|
||
}
|
||
|
||
// ─── File browser (display task only for LVGL parts) ────────────────────────
|
||
|
||
static void build_browser_screen(void) {
|
||
s_scr_browser = lv_obj_create(NULL);
|
||
lv_obj_set_style_bg_color(s_scr_browser, lv_color_hex(0x000000), 0);
|
||
lv_obj_set_style_bg_opa(s_scr_browser, LV_OPA_COVER, 0);
|
||
|
||
lv_obj_t *bar = lv_obj_create(s_scr_browser);
|
||
lv_obj_remove_style_all(bar);
|
||
lv_obj_set_size(bar, DUI_HOR_RES, 36);
|
||
lv_obj_set_style_bg_color(bar, lv_color_hex(COL_TITLEBAR), 0);
|
||
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0);
|
||
s_browser_title = lv_label_create(bar);
|
||
lv_obj_set_style_text_color(s_browser_title, lv_color_hex(COL_TITLE_TXT), 0);
|
||
lv_obj_set_style_text_font(s_browser_title, &lv_font_ibmplexmono_18, 0);
|
||
lv_label_set_text(s_browser_title, s_browser_path);
|
||
lv_obj_align(s_browser_title, LV_ALIGN_LEFT_MID, 8, 0);
|
||
|
||
s_browser_list = lv_obj_create(s_scr_browser);
|
||
lv_obj_remove_style_all(s_browser_list);
|
||
lv_obj_set_size(s_browser_list, DUI_HOR_RES, DUI_VER_RES - 36);
|
||
lv_obj_set_pos(s_browser_list, 0, 36);
|
||
}
|
||
|
||
static void browser_reload(void) {
|
||
s_browser_count = 0;
|
||
DIR *dir = opendir(s_browser_path);
|
||
if (dir) {
|
||
struct dirent *ent;
|
||
while ((ent = readdir(dir)) != NULL &&
|
||
s_browser_count < BROWSER_MAX_ENTRIES) {
|
||
strncpy(s_browser_items[s_browser_count], ent->d_name,
|
||
sizeof(s_browser_items[0]) - 1);
|
||
s_browser_items[s_browser_count][sizeof(s_browser_items[0]) - 1] = '\0';
|
||
s_browser_isdir[s_browser_count] = (ent->d_type == DT_DIR);
|
||
s_browser_count++;
|
||
}
|
||
closedir(dir);
|
||
}
|
||
|
||
lv_obj_clean(s_browser_list);
|
||
lv_label_set_text(s_browser_title, s_browser_path);
|
||
for (int i = 0; i < s_browser_count; i++) {
|
||
lv_obj_t *row = lv_label_create(s_browser_list);
|
||
lv_obj_set_style_text_font(row, &lv_font_ibmplexmono_18, 0);
|
||
lv_obj_set_pos(row, 12, 6 + i * 22);
|
||
char line[56];
|
||
snprintf(line, sizeof(line), "%s%s",
|
||
s_browser_isdir[i] ? "[D] " : " ", s_browser_items[i]);
|
||
lv_label_set_text(row, line);
|
||
lv_obj_set_style_text_color(row,
|
||
(i == s_browser_sel) ? lv_color_hex(COL_CODE) : lv_color_white(), 0);
|
||
}
|
||
if (s_browser_count == 0) {
|
||
lv_obj_t *row = lv_label_create(s_browser_list);
|
||
lv_obj_set_style_text_font(row, &lv_font_ibmplexmono_18, 0);
|
||
lv_obj_set_pos(row, 12, 6);
|
||
lv_obj_set_style_text_color(row, lv_color_hex(COL_LABEL), 0);
|
||
lv_label_set_text(row, "(vide)");
|
||
}
|
||
}
|
||
|
||
static void build_intro_screen(void) {
|
||
s_scr_intro = lv_obj_create(NULL);
|
||
lv_obj_set_style_bg_color(s_scr_intro, lv_color_hex(0x000000), 0);
|
||
lv_obj_set_style_bg_opa(s_scr_intro, LV_OPA_COVER, 0);
|
||
|
||
// Starfield — original layer split: 50% layer0, 30% layer1, 20% layer2.
|
||
static const int16_t kSpeeds[3] = {54, 116, 198}; // speeds_fast
|
||
const int l0_end = (INTRO_STARS * 50) / 100;
|
||
const int l1_end = (INTRO_STARS * 80) / 100;
|
||
for (int i = 0; i < INTRO_STARS; i++) {
|
||
const uint8_t layer = (i < l0_end) ? 0 : (i < l1_end) ? 1 : 2;
|
||
intro_star_t *st = &s_stars[i];
|
||
st->layer = layer;
|
||
st->speed = kSpeeds[layer];
|
||
st->x_q8 = (int32_t) (esp_random() % DUI_HOR_RES) << 8;
|
||
st->y_q8 = (int32_t) (esp_random() % DUI_VER_RES) << 8;
|
||
|
||
lv_obj_t *star = lv_obj_create(s_scr_intro);
|
||
lv_obj_remove_style_all(star);
|
||
lv_obj_set_size(star, 1 + layer, 1 + layer);
|
||
lv_obj_set_style_radius(star, LV_RADIUS_CIRCLE, 0);
|
||
lv_obj_set_style_bg_color(star,
|
||
(layer == 2) ? lv_color_hex(0x00FFFF) : lv_color_white(), 0);
|
||
lv_obj_set_style_bg_opa(star,
|
||
(layer == 0) ? LV_OPA_30 : (layer == 1) ? LV_OPA_60 : LV_OPA_COVER, 0);
|
||
lv_obj_set_pos(star, st->x_q8 >> 8, st->y_q8 >> 8);
|
||
s_star_objs[i] = star;
|
||
}
|
||
|
||
// Copper bars (4 horizontal gradient bars bobbing on sines).
|
||
static const uint32_t kBarCols[INTRO_BARS] =
|
||
{0xFF0088, 0x0088FF, 0x00FF88, 0xFFFF00};
|
||
for (int i = 0; i < INTRO_BARS; i++) {
|
||
lv_obj_t *bar = lv_obj_create(s_scr_intro);
|
||
lv_obj_remove_style_all(bar);
|
||
lv_obj_set_size(bar, DUI_HOR_RES, 6);
|
||
lv_obj_set_style_bg_color(bar, lv_color_hex(kBarCols[i]), 0);
|
||
lv_obj_set_style_bg_opa(bar, LV_OPA_70, 0);
|
||
lv_obj_set_pos(bar, 0, 60 + i * 14);
|
||
s_intro_bars[i] = bar;
|
||
}
|
||
|
||
// Logo + drop shadow (original offsets: shadow at TOP_MID +1,+23;
|
||
// logo at TOP_MID 0,22 — shadow drawn first, behind).
|
||
s_intro_logo_shadow = lv_label_create(s_scr_intro);
|
||
lv_obj_set_style_text_color(s_intro_logo_shadow, lv_color_hex(0x222222), 0);
|
||
lv_obj_set_style_text_font(s_intro_logo_shadow, &lv_font_orbitron_40, 0);
|
||
lv_label_set_text(s_intro_logo_shadow, "Professeur ZACUS");
|
||
lv_obj_align(s_intro_logo_shadow, LV_ALIGN_TOP_MID, 1, 23);
|
||
|
||
s_intro_logo = lv_label_create(s_scr_intro);
|
||
lv_obj_set_style_text_color(s_intro_logo, lv_color_hex(COL_CODE), 0);
|
||
lv_obj_set_style_text_font(s_intro_logo, &lv_font_orbitron_40, 0);
|
||
lv_label_set_text(s_intro_logo, "Professeur ZACUS");
|
||
lv_obj_align(s_intro_logo, LV_ALIGN_TOP_MID, 0, 22);
|
||
|
||
// Bottom sine scroller.
|
||
s_intro_scroll = lv_label_create(s_scr_intro);
|
||
lv_obj_set_style_text_color(s_intro_scroll, lv_color_hex(0x00FFFF), 0);
|
||
lv_obj_set_style_text_font(s_intro_scroll, &lv_font_ibmplexmono_18, 0);
|
||
lv_label_set_text(s_intro_scroll,
|
||
"*** ZACUS MASTER CRACKTRO *** LE MYSTERE CONTINUE ... "
|
||
"GREETINGS TO THE GAME MASTER ... ");
|
||
s_scroll_x_q8 = (int32_t) DUI_HOR_RES << 8;
|
||
lv_obj_set_pos(s_intro_scroll, DUI_HOR_RES, DUI_VER_RES - 60);
|
||
}
|
||
|
||
// Phase B entry: allocate the FX module + a full-screen canvas behind the
|
||
// logo/scroller, hide the phase-A starfield + bars. One-shot; on failure the
|
||
// intro simply keeps the starfield (s_fx_ok stays false). Display task only.
|
||
static bool intro_fx_setup(void) {
|
||
if (!fx3d_init()) return false;
|
||
// Fisher-Yates over the 6-FX pool, keep the first 3: each boot shows a
|
||
// different random selection/order without lengthening the intro.
|
||
fx3d_mode_t pool[FX3D_MODE_COUNT];
|
||
for (int i = 0; i < FX3D_MODE_COUNT; i++) pool[i] = (fx3d_mode_t) i;
|
||
for (int i = FX3D_MODE_COUNT - 1; i > 0; i--) {
|
||
const int j = (int) (esp_random() % (uint32_t) (i + 1));
|
||
const fx3d_mode_t tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
|
||
}
|
||
for (int i = 0; i < 3; i++) s_fx_order[i] = pool[i];
|
||
ESP_LOGI(TAG, "intro fx: order=%d,%d,%d (0=roto 1=sphere 2=corridor "
|
||
"3=star3d 4=voxel 5=cube)",
|
||
(int) s_fx_order[0], (int) s_fx_order[1], (int) s_fx_order[2]);
|
||
s_fx_canvas_buf = (lv_color_t *) heap_caps_malloc(
|
||
(size_t) DUI_HOR_RES * DUI_VER_RES * sizeof(lv_color_t),
|
||
MALLOC_CAP_SPIRAM);
|
||
if (!s_fx_canvas_buf) return false;
|
||
s_fx_canvas = lv_canvas_create(s_scr_intro);
|
||
lv_canvas_set_buffer(s_fx_canvas, s_fx_canvas_buf,
|
||
DUI_HOR_RES, DUI_VER_RES, LV_IMG_CF_TRUE_COLOR);
|
||
lv_obj_set_pos(s_fx_canvas, 0, 0);
|
||
lv_obj_move_background(s_fx_canvas);
|
||
for (int i = 0; i < INTRO_STARS; i++)
|
||
lv_obj_add_flag(s_star_objs[i], LV_OBJ_FLAG_HIDDEN);
|
||
for (int i = 0; i < INTRO_BARS; i++)
|
||
lv_obj_add_flag(s_intro_bars[i], LV_OBJ_FLAG_HIDDEN);
|
||
return true;
|
||
}
|
||
|
||
// Per-tick intro animation (display task only; dt in ms). Faithful Q8.8 math.
|
||
static void update_intro(uint32_t dt_ms) {
|
||
s_intro_t_ms += dt_ms;
|
||
|
||
// Phase sequencing: A starfield, then B/C/D = rotozoom, dot sphere,
|
||
// ray corridor (original fx_mode order) on the FX canvas.
|
||
if (s_intro_t_ms >= INTRO_PHASE_A_MS && !s_fx_tried) {
|
||
s_fx_tried = true;
|
||
s_fx_ok = intro_fx_setup();
|
||
}
|
||
if (s_fx_ok && s_intro_t_ms >= INTRO_PHASE_A_MS) {
|
||
int fx = (int) ((s_intro_t_ms - INTRO_PHASE_A_MS) / INTRO_FX_MS);
|
||
if (fx > 2) fx = 2;
|
||
// Render every other tick (20 ms): the SPI flush of a full-screen
|
||
// canvas dominates anyway, no point racing it.
|
||
static bool skip;
|
||
skip = !skip;
|
||
if (!skip) {
|
||
fx3d_render(s_fx_order[fx], s_intro_t_ms,
|
||
(uint16_t *) s_fx_canvas_buf, DUI_HOR_RES, DUI_VER_RES);
|
||
lv_obj_invalidate(s_fx_canvas);
|
||
}
|
||
goto scroller; // stars/bars hidden — skip their updates
|
||
}
|
||
|
||
// Starfield: fall + wrap + twinkle (original update loop).
|
||
for (int i = 0; i < INTRO_STARS; i++) {
|
||
intro_star_t *st = &s_stars[i];
|
||
st->y_q8 += (int32_t) (((uint32_t) st->speed * dt_ms * 256u) / 1000u);
|
||
if (st->y_q8 > ((int32_t) (DUI_VER_RES + 4) << 8)) {
|
||
st->y_q8 = -(int32_t) ((esp_random() % 36u) << 8);
|
||
st->x_q8 = (int32_t) (esp_random() % DUI_HOR_RES) << 8;
|
||
}
|
||
if ((esp_random() & 0x7u) == 0u) {
|
||
lv_obj_set_style_bg_opa(s_star_objs[i],
|
||
(lv_opa_t) (96u + (esp_random() % 160u)), 0);
|
||
}
|
||
lv_obj_set_pos(s_star_objs[i], st->x_q8 >> 8, st->y_q8 >> 8);
|
||
}
|
||
|
||
// Copper bars: vertical sine bob, phase-shifted per bar.
|
||
for (int i = 0; i < INTRO_BARS; i++) {
|
||
const float ph = (float) s_intro_t_ms * 0.004f + (float) i * 0.9f;
|
||
lv_obj_set_y(s_intro_bars[i], 60 + i * 14 + (int) (sinf(ph) * 18.0f));
|
||
}
|
||
|
||
scroller:
|
||
// Sine scroller: leftward at INTRO_SCROLL_PXS, y rides a sine of the
|
||
// x position (original sine_amp/sine_period defaults).
|
||
s_scroll_x_q8 -= (int32_t) ((INTRO_SCROLL_PXS * dt_ms * 256u) / 1000u);
|
||
const int sx = s_scroll_x_q8 >> 8;
|
||
const int w = lv_obj_get_width(s_intro_scroll);
|
||
if (sx < -w) s_scroll_x_q8 = (int32_t) DUI_HOR_RES << 8;
|
||
const int sy = DUI_VER_RES - 60 +
|
||
(int) (sinf((float) sx / INTRO_SINE_PERIOD * 6.2832f) *
|
||
INTRO_SINE_AMP);
|
||
lv_obj_set_pos(s_intro_scroll, sx, sy);
|
||
}
|
||
|
||
static void build_library_screen(void) {
|
||
s_scr_library = lv_obj_create(NULL);
|
||
lv_obj_set_style_bg_color(s_scr_library, lv_color_hex(0x000000), 0);
|
||
lv_obj_set_style_bg_opa(s_scr_library, LV_OPA_COVER, 0);
|
||
lv_obj_clear_flag(s_scr_library, LV_OBJ_FLAG_SCROLLABLE);
|
||
|
||
lv_obj_t *hdr = lv_label_create(s_scr_library);
|
||
lv_obj_set_style_text_color(hdr, lv_color_hex(COL_CODE), 0);
|
||
lv_obj_set_style_text_font(hdr, &lv_font_montserrat_14, 0);
|
||
lv_label_set_text(hdr, "Choisis ton histoire");
|
||
lv_obj_align(hdr, LV_ALIGN_TOP_MID, 0, 4);
|
||
|
||
for (int i = 0; i < LIB_TILES; i++) {
|
||
int col = i % 2, row = i / 2;
|
||
lv_obj_t *t = lv_obj_create(s_scr_library);
|
||
lv_obj_set_size(t, 228, 84);
|
||
lv_obj_set_pos(t, 8 + col * 236, 26 + row * 94);
|
||
lv_obj_set_style_radius(t, 8, 0);
|
||
lv_obj_set_style_bg_color(t, lv_color_hex(0x101820), 0);
|
||
lv_obj_set_style_bg_opa(t, LV_OPA_COVER, 0);
|
||
lv_obj_set_style_border_width(t, 2, 0);
|
||
lv_obj_set_style_border_color(t, lv_color_hex(0x445566), 0);
|
||
lv_obj_set_style_pad_all(t, 6, 0);
|
||
lv_obj_clear_flag(t, LV_OBJ_FLAG_SCROLLABLE);
|
||
lv_obj_t *l = lv_label_create(t);
|
||
lv_obj_set_style_text_color(l, lv_color_hex(COL_VALUE), 0);
|
||
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
|
||
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
|
||
lv_obj_set_width(l, 208);
|
||
lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0);
|
||
lv_label_set_text(l, "");
|
||
lv_obj_center(l);
|
||
s_lib_tile[i] = t;
|
||
s_lib_label[i] = l;
|
||
}
|
||
}
|
||
|
||
static void build_gamebook_screen(void) {
|
||
s_scr_gamebook = lv_obj_create(NULL);
|
||
lv_obj_set_style_bg_color(s_scr_gamebook, lv_color_hex(0x000000), 0);
|
||
lv_obj_set_style_bg_opa(s_scr_gamebook, LV_OPA_COVER, 0);
|
||
|
||
// Compact title line at the very top.
|
||
s_gb_title = lv_label_create(s_scr_gamebook);
|
||
lv_obj_set_style_text_color(s_gb_title, lv_color_hex(COL_CODE), 0);
|
||
lv_obj_set_style_text_font(s_gb_title, &lv_font_montserrat_14, 0);
|
||
lv_label_set_long_mode(s_gb_title, LV_LABEL_LONG_DOT);
|
||
lv_obj_set_width(s_gb_title, DUI_HOR_RES - 12);
|
||
lv_obj_set_style_text_align(s_gb_title, LV_TEXT_ALIGN_CENTER, 0);
|
||
lv_label_set_text(s_gb_title, "");
|
||
lv_obj_align(s_gb_title, LV_ALIGN_TOP_MID, 0, 4);
|
||
|
||
// Choice selector: a single line pinned at the very bottom. Left/Right cycle
|
||
// the selection, click confirms. Drawn last (foreground).
|
||
s_gb_menu = lv_label_create(s_scr_gamebook);
|
||
lv_obj_set_style_text_color(s_gb_menu, lv_color_hex(COL_CODE), 0);
|
||
lv_obj_set_style_bg_color(s_gb_menu, lv_color_hex(0x182230), 0);
|
||
lv_obj_set_style_bg_opa(s_gb_menu, LV_OPA_COVER, 0);
|
||
lv_obj_set_style_pad_all(s_gb_menu, 5, 0);
|
||
lv_obj_set_style_text_font(s_gb_menu, &lv_font_montserrat_14, 0);
|
||
lv_label_set_long_mode(s_gb_menu, LV_LABEL_LONG_DOT);
|
||
lv_obj_set_width(s_gb_menu, DUI_HOR_RES);
|
||
lv_obj_set_style_text_align(s_gb_menu, LV_TEXT_ALIGN_CENTER, 0);
|
||
lv_label_set_text(s_gb_menu, "");
|
||
lv_obj_align(s_gb_menu, LV_ALIGN_BOTTOM_MID, 0, 0);
|
||
|
||
// Reading area: a vertically-scrollable container filling the whole height
|
||
// between the title and the choice line. Up/Down scroll it by half-pages.
|
||
s_gb_body_cont = lv_obj_create(s_scr_gamebook);
|
||
lv_obj_set_style_bg_color(s_gb_body_cont, lv_color_hex(0x000000), 0);
|
||
lv_obj_set_style_bg_opa(s_gb_body_cont, LV_OPA_COVER, 0);
|
||
lv_obj_set_style_border_width(s_gb_body_cont, 0, 0);
|
||
lv_obj_set_style_pad_all(s_gb_body_cont, 6, 0);
|
||
lv_obj_set_size(s_gb_body_cont, DUI_HOR_RES, 264); // 24 (title) .. 288 (menu)
|
||
lv_obj_set_pos(s_gb_body_cont, 0, 24);
|
||
lv_obj_set_scroll_dir(s_gb_body_cont, LV_DIR_VER);
|
||
lv_obj_set_scrollbar_mode(s_gb_body_cont, LV_SCROLLBAR_MODE_AUTO);
|
||
|
||
s_gb_body = lv_label_create(s_gb_body_cont);
|
||
lv_obj_set_style_text_color(s_gb_body, lv_color_hex(COL_VALUE), 0);
|
||
lv_obj_set_style_text_font(s_gb_body, &lv_font_montserrat_14, 0);
|
||
lv_label_set_long_mode(s_gb_body, LV_LABEL_LONG_WRAP);
|
||
lv_obj_set_width(s_gb_body, DUI_HOR_RES - 24); // container width minus padding
|
||
lv_label_set_text(s_gb_body, "");
|
||
lv_obj_align(s_gb_body, LV_ALIGN_TOP_LEFT, 0, 0);
|
||
}
|
||
|
||
static void build_status_screen(void) {
|
||
lv_obj_t *scr = s_scr_status;
|
||
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0);
|
||
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
|
||
|
||
// Title bar
|
||
lv_obj_t *bar = lv_obj_create(scr);
|
||
lv_obj_remove_style_all(bar);
|
||
lv_obj_set_size(bar, DUI_HOR_RES, 36);
|
||
lv_obj_set_pos(bar, 0, 0);
|
||
lv_obj_set_style_bg_color(bar, lv_color_hex(COL_TITLEBAR), 0);
|
||
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0);
|
||
|
||
s_lbl_title = lv_label_create(bar);
|
||
lv_obj_set_style_text_color(s_lbl_title, lv_color_hex(COL_TITLE_TXT), 0);
|
||
lv_obj_set_style_text_font(s_lbl_title, &lv_font_montserrat_24, 0);
|
||
lv_label_set_text(s_lbl_title, "ZACUS MASTER");
|
||
lv_obj_align(s_lbl_title, LV_ALIGN_LEFT_MID, 8, 0);
|
||
|
||
// Rows
|
||
int y = 52;
|
||
const int LINE_H = 30;
|
||
make_row(scr, y, "IP", &s_row_ip); y += LINE_H;
|
||
make_row(scr, y, "Wake", &s_row_wake); y += LINE_H;
|
||
make_row(scr, y, "Etape", &s_row_step); y += LINE_H;
|
||
make_row(scr, y, "Enigme", &s_row_armed); y += LINE_H;
|
||
make_row(scr, y, "Resolues", &s_row_solved); y += LINE_H;
|
||
|
||
// Assembled code — big, orange
|
||
s_lbl_code = lv_label_create(scr);
|
||
lv_obj_set_style_text_color(s_lbl_code, lv_color_hex(COL_CODE), 0);
|
||
lv_obj_set_style_text_font(s_lbl_code, &lv_font_montserrat_28, 0);
|
||
lv_label_set_text(s_lbl_code, "Code: ---");
|
||
lv_obj_set_pos(s_lbl_code, 8, y + 12);
|
||
}
|
||
|
||
static void apply_status(const display_status_t *s) {
|
||
// Status screen rows
|
||
lv_label_set_text(s_row_ip.value, s->ip[0] ? s->ip : "(aucune)");
|
||
lv_label_set_text(s_row_wake.value, s->wake_active ? "oui" : "non");
|
||
lv_label_set_text(s_row_step.value, s->step_id[0] ? s->step_id : "(aucune)");
|
||
lv_label_set_text(s_row_armed.value, s->armed[0] ? s->armed : "none");
|
||
|
||
char tmp[48];
|
||
snprintf(tmp, sizeof(tmp), "%u", (unsigned) s->solved_count);
|
||
lv_label_set_text(s_row_solved.value, tmp);
|
||
|
||
char code[32];
|
||
snprintf(code, sizeof(code), "Code: %s", s->code[0] ? s->code : "---");
|
||
lv_label_set_text(s_lbl_code, code);
|
||
|
||
// Scene screen — scene metadata wins, with sensible fallbacks.
|
||
lv_label_set_text(s_scene_title,
|
||
s->scene_title[0] ? s->scene_title : s->step_id);
|
||
if (s->scene_subtitle[0]) {
|
||
lv_label_set_text(s_scene_subtitle, s->scene_subtitle);
|
||
} else {
|
||
if (s->armed[0] && strcmp(s->armed, "none") != 0) {
|
||
snprintf(tmp, sizeof(tmp), "enigme: %s", s->armed);
|
||
} else {
|
||
snprintf(tmp, sizeof(tmp), "resolues: %u",
|
||
(unsigned) s->solved_count);
|
||
}
|
||
lv_label_set_text(s_scene_subtitle, tmp);
|
||
}
|
||
// Center slot: scenario symbol when provided, else the assembled code.
|
||
lv_label_set_text(s_scene_code,
|
||
s->scene_symbol[0] ? s->scene_symbol
|
||
: (s->code[0] ? s->code : ""));
|
||
apply_effect(s->scene_effect);
|
||
|
||
// Viewfinder: visible only while the QR puzzle is armed. When visible,
|
||
// the centered code label yields its slot to the canvas (code moves into
|
||
// the subtitle line).
|
||
s_cam_visible = (s_scene_cam != NULL) && (strcmp(s->armed, "qr") == 0);
|
||
if (s_scene_cam) {
|
||
if (s_cam_visible) {
|
||
lv_obj_clear_flag(s_scene_cam, LV_OBJ_FLAG_HIDDEN);
|
||
lv_obj_add_flag(s_scene_code, LV_OBJ_FLAG_HIDDEN);
|
||
if (s->code[0]) {
|
||
snprintf(tmp, sizeof(tmp), "enigme: qr code: %s", s->code);
|
||
lv_label_set_text(s_scene_subtitle, tmp);
|
||
}
|
||
} else {
|
||
lv_obj_add_flag(s_scene_cam, LV_OBJ_FLAG_HIDDEN);
|
||
lv_obj_clear_flag(s_scene_code, LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
}
|
||
|
||
// Active step → scene view; idle → status view. The 5-way buttons can
|
||
// override (1=force status, 2=force scene; 0=auto). Fade 240 ms, the
|
||
// original ui_manager default transition (SceneTransition::kFade, 240).
|
||
// Gamebook page: refresh its labels from the latest show() buffers, then
|
||
// apply any pending manual scroll (Up/Down) on the reading container.
|
||
if (s_gamebook_open) {
|
||
lv_label_set_text(s_gb_title, s_gb_title_buf);
|
||
lv_label_set_text(s_gb_body, s_gb_body_buf);
|
||
lv_label_set_text(s_gb_menu, s_gb_menu_buf);
|
||
if (s_gb_scroll_home) {
|
||
lv_obj_scroll_to_y(s_gb_body_cont, 0, LV_ANIM_OFF);
|
||
s_gb_scroll_home = false;
|
||
}
|
||
int req = s_gb_scroll_req;
|
||
if (req != 0) {
|
||
s_gb_scroll_req = 0;
|
||
// +1 page = read further down (content moves up): negative dy.
|
||
lv_obj_scroll_by(s_gb_body_cont, 0, -req * 130, LV_ANIM_ON);
|
||
}
|
||
}
|
||
// Library grid: refresh tile titles + highlight the selected one.
|
||
if (s_library_open) {
|
||
for (int i = 0; i < LIB_TILES; i++) {
|
||
if (i < s_lib_count_buf) {
|
||
lv_label_set_text(s_lib_label[i], s_lib_title_buf[i]);
|
||
lv_obj_clear_flag(s_lib_tile[i], LV_OBJ_FLAG_HIDDEN);
|
||
} else {
|
||
lv_obj_add_flag(s_lib_tile[i], LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
bool on = (i == s_lib_sel_buf);
|
||
lv_obj_set_style_border_color(s_lib_tile[i],
|
||
lv_color_hex(on ? COL_CODE : 0x445566), 0);
|
||
lv_obj_set_style_border_width(s_lib_tile[i], on ? 4 : 2, 0);
|
||
lv_obj_set_style_bg_color(s_lib_tile[i],
|
||
lv_color_hex(on ? 0x223040 : 0x101820), 0);
|
||
}
|
||
}
|
||
|
||
extern volatile uint8_t g_dui_view_override;
|
||
lv_obj_t *want;
|
||
if (!s_intro_done) {
|
||
want = s_scr_intro; // boot intro plays out first
|
||
} else if (s_library_open) {
|
||
want = s_scr_library; // story picker owns the screen
|
||
} else if (s_gamebook_open) {
|
||
want = s_scr_gamebook; // gamebook owns the screen while running
|
||
} else if (s_browser_open) {
|
||
want = s_scr_browser; // file browser on top of everything
|
||
} else if (s_shell_open) {
|
||
want = s_scr_shell; // Workbench shell on top
|
||
} else switch (g_dui_view_override) {
|
||
case 1: want = s_scr_status; break;
|
||
case 2: want = s_scr_scene; break;
|
||
default: want = s->step_id[0] ? s_scr_scene : s_scr_status; break;
|
||
}
|
||
if (lv_scr_act() != want) {
|
||
lv_scr_load_anim(want, LV_SCR_LOAD_ANIM_FADE_ON, 240, 0, false);
|
||
}
|
||
extern volatile bool g_dui_on_scene;
|
||
g_dui_on_scene = (want == s_scr_scene);
|
||
}
|
||
|
||
// View override shared with display_ui_handle_key (byte writes are atomic).
|
||
volatile uint8_t g_dui_view_override = 0;
|
||
// Mirror of the active view, written ONLY by the display task — lets
|
||
// display_ui_handle_key decide without calling LVGL from the buttons task.
|
||
volatile bool g_dui_on_scene = false;
|
||
|
||
// Convert the freshest grayscale frame into the canvas (display task only).
|
||
static void refresh_viewfinder(void) {
|
||
if (!s_cam_visible || !s_cam_fresh || !s_cam_mutex) return;
|
||
|
||
if (xSemaphoreTake(s_cam_mutex, pdMS_TO_TICKS(10)) != pdTRUE) return;
|
||
// Convert in place from the shared gray buffer into the canvas buffer.
|
||
const uint8_t *g = s_cam_gray;
|
||
uint16_t *dst = (uint16_t *) s_cam_canvas_buf;
|
||
for (size_t i = 0; i < (size_t) CAM_W * CAM_H; i++) {
|
||
const uint8_t v = g[i];
|
||
dst[i] = (uint16_t) (((v >> 3) << 11) | ((v >> 2) << 5) | (v >> 3));
|
||
}
|
||
s_cam_fresh = false;
|
||
xSemaphoreGive(s_cam_mutex);
|
||
|
||
lv_obj_invalidate(s_scene_cam);
|
||
}
|
||
|
||
// ─── Raw splash (pre-LVGL, visible during the rest of boot) ───────────────────
|
||
|
||
static void render_splash(void) {
|
||
const int W = s_lcd.width();
|
||
const int H = s_lcd.height();
|
||
|
||
s_lcd.fillScreen(COL_BG);
|
||
s_lcd.fillRect(0, 0, W, 36, COL_TITLEBAR);
|
||
s_lcd.setTextColor(COL_TITLE_TXT);
|
||
s_lcd.setFont(&fonts::Font4);
|
||
s_lcd.setCursor(8, 8);
|
||
s_lcd.print("ZACUS MASTER");
|
||
|
||
s_lcd.setFont(&fonts::Font2);
|
||
s_lcd.setTextColor(COL_VALUE);
|
||
s_lcd.setCursor(8, 50);
|
||
s_lcd.print("Demarrage...");
|
||
|
||
s_lcd.setTextColor(COL_LABEL);
|
||
s_lcd.setCursor(8, H - 20);
|
||
s_lcd.print("FNK0102H ESP-IDF 5.4 Phase 2a");
|
||
}
|
||
|
||
// ─── Backlight (LEDC PWM — free pair; camera XCLK owns TIMER_0/CHANNEL_0) ─────
|
||
|
||
#define DUI_BL_TIMER LEDC_TIMER_1
|
||
#define DUI_BL_CHANNEL LEDC_CHANNEL_1
|
||
#define DUI_BL_FREQ_HZ 44100 // like the original Light_PWM
|
||
#define DUI_BL_RES LEDC_TIMER_8_BIT
|
||
|
||
static esp_err_t backlight_init(void) {
|
||
ledc_timer_config_t tcfg = {};
|
||
tcfg.speed_mode = LEDC_LOW_SPEED_MODE;
|
||
tcfg.timer_num = DUI_BL_TIMER;
|
||
tcfg.duty_resolution = DUI_BL_RES;
|
||
tcfg.freq_hz = DUI_BL_FREQ_HZ;
|
||
tcfg.clk_cfg = LEDC_AUTO_CLK;
|
||
esp_err_t err = ledc_timer_config(&tcfg);
|
||
if (err != ESP_OK) return err;
|
||
|
||
ledc_channel_config_t ccfg = {};
|
||
ccfg.gpio_num = TFT_PIN_BL;
|
||
ccfg.speed_mode = LEDC_LOW_SPEED_MODE;
|
||
ccfg.channel = DUI_BL_CHANNEL;
|
||
ccfg.timer_sel = DUI_BL_TIMER;
|
||
ccfg.duty = 255; // full on by default
|
||
ccfg.hpoint = 0;
|
||
return ledc_channel_config(&ccfg);
|
||
}
|
||
|
||
extern "C" void display_ui_set_brightness(uint8_t pct) {
|
||
if (pct > 100) pct = 100;
|
||
const uint32_t duty = (255u * pct) / 100u;
|
||
ledc_set_duty(LEDC_LOW_SPEED_MODE, DUI_BL_CHANNEL, duty);
|
||
ledc_update_duty(LEDC_LOW_SPEED_MODE, DUI_BL_CHANNEL);
|
||
}
|
||
|
||
// ─── Display task ─────────────────────────────────────────────────────────────
|
||
|
||
static void display_task(void *arg) {
|
||
(void) arg;
|
||
|
||
// LVGL lives entirely on this task.
|
||
s_scr_status = lv_scr_act();
|
||
build_status_screen();
|
||
build_scene_screen();
|
||
build_shell_screen();
|
||
build_gamebook_screen();
|
||
build_library_screen();
|
||
build_browser_screen();
|
||
build_intro_screen();
|
||
|
||
// Boot intro: shown immediately, hands over after INTRO_DURATION_MS
|
||
// (any 5-way key skips it — see display_ui_handle_key).
|
||
lv_scr_load(s_scr_intro);
|
||
TickType_t intro_until = xTaskGetTickCount() +
|
||
pdMS_TO_TICKS(INTRO_DURATION_MS);
|
||
|
||
display_status_t local_status;
|
||
memset(&local_status, 0, sizeof(local_status));
|
||
|
||
TickType_t last_cam = 0;
|
||
uint8_t shell_sel_applied = 0xFF;
|
||
bool shell_open_applied = false;
|
||
|
||
for (;;) {
|
||
// Intro: animate while active, hand over at the deadline. dt is the
|
||
// REAL elapsed time: the loop period is vTaskDelay(10) + LVGL render
|
||
// (up to ~30 ms on a full-canvas frame), so a fixed dt=10 would make
|
||
// the intro clock lag wall time and starve the FX phases.
|
||
if (!s_intro_done) {
|
||
static TickType_t intro_prev_ticks;
|
||
const TickType_t now_ticks = xTaskGetTickCount();
|
||
if (intro_prev_ticks == 0) intro_prev_ticks = now_ticks;
|
||
uint32_t dt = (uint32_t) pdTICKS_TO_MS(now_ticks - intro_prev_ticks);
|
||
intro_prev_ticks = now_ticks;
|
||
if (dt > 100) dt = 100;
|
||
update_intro(dt);
|
||
if (now_ticks >= intro_until) {
|
||
s_intro_done = true;
|
||
s_dirty = true; // re-evaluate the wanted screen
|
||
}
|
||
}
|
||
|
||
// Shell state (selection highlight / open-close) from the buttons task.
|
||
if (s_shell_sel != shell_sel_applied ||
|
||
s_shell_open != shell_open_applied) {
|
||
shell_sel_applied = s_shell_sel;
|
||
shell_open_applied = s_shell_open;
|
||
if (s_shell_open) shell_populate(); // lazy: littlefs mounts late
|
||
apply_shell_selection();
|
||
s_dirty = true;
|
||
}
|
||
|
||
// Browser reload requested (path change / open / selection move).
|
||
if (s_browser_reload) {
|
||
s_browser_reload = false;
|
||
browser_reload();
|
||
s_dirty = true;
|
||
}
|
||
|
||
if (s_dirty &&
|
||
xSemaphoreTake(s_mutex, pdMS_TO_TICKS(20)) == pdTRUE) {
|
||
memcpy(&local_status, &s_status, sizeof(local_status));
|
||
s_dirty = false;
|
||
xSemaphoreGive(s_mutex);
|
||
apply_status(&local_status);
|
||
}
|
||
|
||
// Viewfinder refresh, throttled to ~5 fps (full-frame gray→rgb565
|
||
// conversion is ~77k pixels — keep the UI task responsive).
|
||
const TickType_t now = xTaskGetTickCount();
|
||
if ((now - last_cam) >= pdMS_TO_TICKS(200)) {
|
||
refresh_viewfinder();
|
||
last_cam = now;
|
||
}
|
||
|
||
lv_timer_handler();
|
||
vTaskDelay(pdMS_TO_TICKS(10));
|
||
}
|
||
}
|
||
|
||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||
|
||
extern "C" esp_err_t display_ui_init(void) {
|
||
// Backlight PWM up first (panel garbage stays dark otherwise).
|
||
esp_err_t err = backlight_init();
|
||
if (err != ESP_OK) {
|
||
ESP_LOGW(TAG, "backlight LEDC init: %s — falling back to plain GPIO",
|
||
esp_err_to_name(err));
|
||
gpio_config_t bl_cfg = {};
|
||
bl_cfg.pin_bit_mask = (1ULL << TFT_PIN_BL);
|
||
bl_cfg.mode = GPIO_MODE_OUTPUT;
|
||
gpio_config(&bl_cfg);
|
||
gpio_set_level((gpio_num_t) TFT_PIN_BL, 1);
|
||
}
|
||
|
||
// Initialise panel
|
||
if (!s_lcd.init()) {
|
||
ESP_LOGE(TAG, "LovyanGFX panel init failed");
|
||
return ESP_FAIL;
|
||
}
|
||
s_lcd.setRotation(1); // landscape, matches FREENOVE_LCD_ROTATION 1
|
||
|
||
ESP_LOGI(TAG, "ST7796 panel up (%dx%d)",
|
||
(int) s_lcd.width(), (int) s_lcd.height());
|
||
|
||
render_splash();
|
||
|
||
// ── LVGL ──────────────────────────────────────────────────────────────
|
||
lv_init();
|
||
|
||
// Draw buffer: width × 40 lines, single. DEVIATION from the original
|
||
// (draw_in_psram = false): on this firmware the camera DMA needs a 30 KB
|
||
// contiguous INTERNAL block (cam_hal), and a 38.4 KB internal draw buffer
|
||
// starved it (largest free block dropped to 28 KB → esp_camera_init
|
||
// ESP_FAIL). PSRAM costs some blit speed in CPU mode but keeps internal
|
||
// RAM for camera/voice DMA. The original Arduino UI never ran esp-sr +
|
||
// WS streaming + camera concurrently, so it could afford internal.
|
||
const size_t buf_px = (size_t) DUI_HOR_RES * DUI_BUF_LINES;
|
||
s_buf1 = (lv_color_t *) heap_caps_malloc(buf_px * sizeof(lv_color_t),
|
||
MALLOC_CAP_SPIRAM);
|
||
if (!s_buf1) {
|
||
ESP_LOGE(TAG, "LVGL draw buffer alloc failed (%u B)",
|
||
(unsigned) (buf_px * sizeof(lv_color_t)));
|
||
return ESP_ERR_NO_MEM;
|
||
}
|
||
lv_disp_draw_buf_init(&s_draw_buf, s_buf1, NULL, buf_px);
|
||
|
||
lv_disp_drv_init(&s_disp_drv);
|
||
s_disp_drv.hor_res = DUI_HOR_RES;
|
||
s_disp_drv.ver_res = DUI_VER_RES;
|
||
s_disp_drv.flush_cb = lvgl_flush_cb;
|
||
s_disp_drv.draw_buf = &s_draw_buf;
|
||
if (!lv_disp_drv_register(&s_disp_drv)) {
|
||
ESP_LOGE(TAG, "lv_disp_drv_register failed");
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
// Tick: 2 ms periodic (LV_TICK_CUSTOM off, like the original).
|
||
const esp_timer_create_args_t targs = {
|
||
.callback = lvgl_tick_cb,
|
||
.arg = NULL,
|
||
.dispatch_method = ESP_TIMER_TASK,
|
||
.name = "lv_tick",
|
||
.skip_unhandled_events = true,
|
||
};
|
||
err = esp_timer_create(&targs, &s_tick_timer);
|
||
if (err == ESP_OK) err = esp_timer_start_periodic(s_tick_timer, 2000);
|
||
if (err != ESP_OK) {
|
||
ESP_LOGE(TAG, "LVGL tick timer: %s", esp_err_to_name(err));
|
||
return err;
|
||
}
|
||
|
||
ESP_LOGI(TAG, "LVGL %d.%d up (buf %u lines, PSRAM)",
|
||
lv_version_major(), lv_version_minor(), (unsigned) DUI_BUF_LINES);
|
||
|
||
// State
|
||
s_mutex = xSemaphoreCreateMutex();
|
||
s_cam_mutex = xSemaphoreCreateMutex();
|
||
if (!s_mutex || !s_cam_mutex) {
|
||
ESP_LOGE(TAG, "failed to create mutex");
|
||
return ESP_ERR_NO_MEM;
|
||
}
|
||
memset(&s_status, 0, sizeof(s_status));
|
||
s_dirty = false;
|
||
s_running = true;
|
||
|
||
// Spawn refresh task (ALL LVGL + panel access lives here).
|
||
BaseType_t rc = xTaskCreate(display_task, "display_ui", 8192, NULL, 3, NULL);
|
||
if (rc != pdPASS) {
|
||
ESP_LOGE(TAG, "xTaskCreate failed");
|
||
return ESP_ERR_NO_MEM;
|
||
}
|
||
|
||
ESP_LOGI(TAG, "display_ui_init OK — LVGL status screen active");
|
||
return ESP_OK;
|
||
}
|
||
|
||
extern "C" void display_ui_set_status(const display_status_t *s) {
|
||
if (!s || !s_running || !s_mutex) {
|
||
return;
|
||
}
|
||
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
|
||
memcpy(&s_status, s, sizeof(s_status));
|
||
s_dirty = true;
|
||
xSemaphoreGive(s_mutex);
|
||
}
|
||
}
|
||
|
||
static display_ui_key_hook_t s_key_hook = nullptr;
|
||
|
||
extern "C" void display_ui_set_key_hook(display_ui_key_hook_t hook) {
|
||
s_key_hook = hook;
|
||
}
|
||
|
||
extern "C" void display_ui_gamebook_show(const char *title, const char *body,
|
||
const char *menu, bool reset_scroll) {
|
||
if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||
snprintf(s_gb_title_buf, sizeof(s_gb_title_buf), "%s", title ? title : "");
|
||
snprintf(s_gb_body_buf, sizeof(s_gb_body_buf), "%s", body ? body : "");
|
||
snprintf(s_gb_menu_buf, sizeof(s_gb_menu_buf), "%s", menu ? menu : "");
|
||
if (reset_scroll) s_gb_scroll_home = true; // new passage → back to top
|
||
s_gamebook_open = true;
|
||
s_dirty = true;
|
||
if (s_mutex) xSemaphoreGive(s_mutex);
|
||
}
|
||
|
||
extern "C" void display_ui_gamebook_scroll(int dir) {
|
||
// dir > 0 : read further down ; dir < 0 : back up. Accumulated and applied
|
||
// on the display task (this runs on the buttons task).
|
||
s_gb_scroll_req += (dir > 0) ? 1 : -1;
|
||
s_dirty = true;
|
||
}
|
||
|
||
extern "C" void display_ui_gamebook_hide(void) {
|
||
s_gamebook_open = false;
|
||
s_gb_scroll_req = 0;
|
||
s_dirty = true;
|
||
}
|
||
|
||
extern "C" void display_ui_library_show(const char *const *titles, int count,
|
||
int sel) {
|
||
if (count > LIB_TILES) count = LIB_TILES;
|
||
if (count < 0) count = 0;
|
||
if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||
s_lib_count_buf = count;
|
||
s_lib_sel_buf = (count > 0) ? ((sel % count + count) % count) : 0;
|
||
for (int i = 0; i < LIB_TILES; i++) {
|
||
snprintf(s_lib_title_buf[i], sizeof(s_lib_title_buf[i]), "%s",
|
||
(i < count && titles && titles[i]) ? titles[i] : "");
|
||
}
|
||
s_library_open = true;
|
||
s_gamebook_open = false; // library and a running story are exclusive
|
||
s_dirty = true;
|
||
if (s_mutex) xSemaphoreGive(s_mutex);
|
||
}
|
||
|
||
extern "C" void display_ui_library_hide(void) {
|
||
s_library_open = false;
|
||
s_dirty = true;
|
||
}
|
||
|
||
extern "C" void display_ui_handle_key(uint8_t key) {
|
||
static uint8_t s_brightness = 100;
|
||
|
||
// Takeover mode (e.g. gamebook): if the hook consumes the key, stop here.
|
||
if (s_key_hook && s_key_hook(key)) {
|
||
return;
|
||
}
|
||
|
||
// Any key skips the boot intro.
|
||
if (!s_intro_done) {
|
||
s_intro_done = true;
|
||
s_dirty = true;
|
||
return;
|
||
}
|
||
|
||
// ── Browser open: list navigation (SELECT enters dirs, MENU goes up
|
||
// then closes at the littlefs root).
|
||
if (s_browser_open) {
|
||
switch (key) {
|
||
case 2: // DOWN
|
||
if (s_browser_sel + 1 < s_browser_count) s_browser_sel++;
|
||
s_browser_reload = true;
|
||
break;
|
||
case 5: // UP
|
||
if (s_browser_sel > 0) s_browser_sel--;
|
||
s_browser_reload = true;
|
||
break;
|
||
case 1: { // SELECT — enter directory
|
||
const uint8_t sel = s_browser_sel;
|
||
if (sel < s_browser_count && s_browser_isdir[sel]) {
|
||
const size_t len = strlen(s_browser_path);
|
||
snprintf(s_browser_path + len, sizeof(s_browser_path) - len,
|
||
"/%s", s_browser_items[sel]);
|
||
s_browser_sel = 0;
|
||
s_browser_reload = true;
|
||
}
|
||
break;
|
||
}
|
||
case 3: { // MENU — up one level, close at root
|
||
char *slash = strrchr(s_browser_path, '/');
|
||
if (slash && slash != s_browser_path &&
|
||
strcmp(s_browser_path, "/littlefs") != 0) {
|
||
*slash = '\0';
|
||
s_browser_sel = 0;
|
||
s_browser_reload = true;
|
||
} else {
|
||
s_browser_open = false;
|
||
s_shell_open = true; // back to the shell
|
||
}
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
s_dirty = true;
|
||
return;
|
||
}
|
||
|
||
// ── Shell open: navigate the Workbench grid (original key semantics:
|
||
// 1=SELECT launch, 2=DOWN row, 3=MENU close, 4=LEFT/RIGHT, 5=UP row).
|
||
if (s_shell_open) {
|
||
switch (key) {
|
||
case 1: { // SELECT — launch tile
|
||
const uint8_t sel = s_shell_sel;
|
||
s_shell_open = false;
|
||
switch (sel) {
|
||
case 0: g_dui_view_override = 1; break; // Statut
|
||
case 1: g_dui_view_override = 2; break; // Scene
|
||
case 2: g_dui_view_override = 0; break; // Auto
|
||
case 3: // Lumiere — cycle
|
||
s_brightness = (s_brightness <= 20) ? 100
|
||
: (uint8_t)(s_brightness - 40);
|
||
display_ui_set_brightness(s_brightness);
|
||
s_shell_open = true; // stay in the shell for feedback
|
||
break;
|
||
case 4: // Fichiers — browser
|
||
strncpy(s_browser_path, "/littlefs", sizeof(s_browser_path));
|
||
s_browser_sel = 0;
|
||
s_browser_open = true;
|
||
s_browser_reload = true;
|
||
break;
|
||
default: // dynamic app
|
||
if (sel < s_shell_count && !s_pending_launch_set) {
|
||
strncpy(s_pending_launch,
|
||
s_shell_dyn[sel - SHELL_BUILTINS].id,
|
||
sizeof(s_pending_launch) - 1);
|
||
s_pending_launch[sizeof(s_pending_launch) - 1] = '\0';
|
||
s_pending_launch_set = true;
|
||
}
|
||
break;
|
||
}
|
||
break;
|
||
}
|
||
case 2: // DOWN — next row
|
||
if (s_shell_sel + SHELL_GRID_COLS < s_shell_count)
|
||
s_shell_sel = (uint8_t)(s_shell_sel + SHELL_GRID_COLS);
|
||
break;
|
||
case 5: // UP — previous row
|
||
if (s_shell_sel >= SHELL_GRID_COLS)
|
||
s_shell_sel = (uint8_t)(s_shell_sel - SHELL_GRID_COLS);
|
||
break;
|
||
case 4: // LEFT/RIGHT — next column (wraps)
|
||
s_shell_sel = (uint8_t)((s_shell_sel + 1) % s_shell_count);
|
||
break;
|
||
case 3: // MENU — close shell
|
||
s_shell_open = false;
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
s_dirty = true;
|
||
return;
|
||
}
|
||
|
||
// ── Shell closed: view shortcuts + brightness.
|
||
switch (key) {
|
||
case 1: // SELECT — toggle scene↔status manually
|
||
case 4: { // LEFT/RIGHT — same toggle
|
||
// g_dui_on_scene is the display task's mirror — no LVGL call here.
|
||
g_dui_view_override = g_dui_on_scene ? 1 : 2;
|
||
s_dirty = true; // wake the display task decision
|
||
break;
|
||
}
|
||
case 3: // MENU — open the Workbench shell
|
||
s_shell_open = true;
|
||
s_dirty = true;
|
||
break;
|
||
case 5: // UP — brightness +
|
||
s_brightness = (s_brightness >= 80) ? 100 : s_brightness + 20;
|
||
display_ui_set_brightness(s_brightness);
|
||
break;
|
||
case 2: // DOWN — brightness −
|
||
s_brightness = (s_brightness <= 20) ? 10 : s_brightness - 20;
|
||
display_ui_set_brightness(s_brightness);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
extern "C" bool display_ui_take_pending_launch(char *id_out, size_t cap) {
|
||
if (!s_pending_launch_set || !id_out || cap == 0) return false;
|
||
strncpy(id_out, s_pending_launch, cap - 1);
|
||
id_out[cap - 1] = '\0';
|
||
s_pending_launch_set = false;
|
||
return true;
|
||
}
|
||
|
||
extern "C" void display_ui_camera_frame(const uint8_t *gray, int width, int height) {
|
||
// Runs in the qr_puzzle scan task — copy fast, never block scanning.
|
||
if (!gray || width != CAM_W || height != CAM_H) return;
|
||
if (!s_running || !s_cam_mutex || !s_cam_gray || !s_cam_visible) return;
|
||
if (xSemaphoreTake(s_cam_mutex, 0) != pdTRUE) return; // skip frame if busy
|
||
memcpy(s_cam_gray, gray, (size_t) CAM_W * CAM_H);
|
||
s_cam_fresh = true;
|
||
xSemaphoreGive(s_cam_mutex);
|
||
}
|