Fixes and new apps (#30)

- M5 Unit Modules library + M5 Unit Test app
- Minor fixes for TodoList, TwoEleven, Snake, Brainfuck and Breakout
- Fixed SerialConsole to use the uart controller as it was broken in one of the many updates
- Fixed keyboard input in TwoEleven, Breakout, Magic8Ball and Snake
- Bluetooth Media Keys app, supports pressing physical keys to trigger the corresponding buttonmatrix button
- Epub Reader app
This commit is contained in:
Shadowtrance
2026-06-07 23:56:32 +10:00
committed by GitHub
parent 1f959c8bbf
commit dbf850c434
127 changed files with 11156 additions and 174 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
Build:
strategy:
matrix:
app_name: [Brainfuck, Breakout, Calculator, Diceware, GPIO, GraphicsDemo, HelloWorld, Magic8Ball, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven]
app_name: [Brainfuck, Breakout, Calculator, Diceware, EpubReader, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+3
View File
@@ -76,6 +76,9 @@ static bool getScriptDir(char* buf, size_t bufSize) {
size_t size = bufSize;
tt_app_get_user_data_path(s_appHandle, buf, &size);
if (size == 0) return false;
for (char* p = buf + 1; *p; ++p) {
if (*p == '/') { *p = '\0'; mkdir(buf, 0755); *p = '/'; }
}
mkdir(buf, 0755);
return true;
}
+4 -3
View File
@@ -5,6 +5,7 @@ sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.brainfuck
versionName=0.1.0
versionCode=1
name=Brainfuck esoteric language interpreter
versionName=0.2.0
versionCode=2
name=Brainfuck interpreter
description=Brainfuck esoteric language interpreter
+42 -15
View File
@@ -13,6 +13,7 @@
#include <tt_lvgl_keyboard.h>
#include <tactility/lvgl_module.h>
#include <tactility/lvgl_fonts.h>
constexpr auto* TAG = "Breakout";
@@ -97,14 +98,19 @@ static uint32_t levelRng(uint32_t& seed) {
// ── UI Creation ──────────────────────────────────────────────
static int getToolbarHeight(UiDensity density) {
if (density == LVGL_UI_DENSITY_COMPACT) {
return 22;
static uint32_t getToolbarHeight(UiDensity uiDensity) {
if (uiDensity == LVGL_UI_DENSITY_COMPACT) {
return lvgl_get_text_font_height(FONT_SIZE_DEFAULT) * 1.4f;
} else {
return 40;
return lvgl_get_text_font_height(FONT_SIZE_LARGE) * 2.2f;
}
}
static uint32_t getActionIconPadding(UiDensity uiDensity) {
auto toolbar_height = getToolbarHeight(uiDensity);
return (uiDensity != LVGL_UI_DENSITY_COMPACT) ? (uint32_t)(toolbar_height * 0.2f) : 8;
}
void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
@@ -157,21 +163,21 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
lv_obj_set_style_text_color(livesLabel, lv_palette_main(LV_PALETTE_RED), 0);
lv_obj_align(livesLabel, LV_ALIGN_CENTER, 0, 0);
auto ui_density = lvgl_get_ui_density();
auto toolbar_height = getToolbarHeight(ui_density);
auto icon_padding = getActionIconPadding(ui_density);
// Toolbar buttons wrapper
lv_obj_t* btnsWrapper = lv_obj_create(toolbar);
lv_obj_set_width(btnsWrapper, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(btnsWrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_all(btnsWrapper, 2, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(btnsWrapper, icon_padding / 2, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(btnsWrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(btnsWrapper, 0, LV_STATE_DEFAULT);
auto ui_density = lvgl_get_ui_density();
auto toolbar_height = getToolbarHeight(ui_density);
int btnSize = (ui_density == LVGL_UI_DENSITY_COMPACT) ? toolbar_height - 8 : toolbar_height - 6;
// Pause button
lv_obj_t* pauseBtn = lv_btn_create(btnsWrapper);
lv_obj_set_size(pauseBtn, btnSize, btnSize);
lv_obj_set_size(pauseBtn, toolbar_height - icon_padding, toolbar_height - icon_padding);
lv_obj_set_style_pad_all(pauseBtn, 0, LV_STATE_DEFAULT);
lv_obj_align(pauseBtn, LV_ALIGN_CENTER, 0, 0);
lv_obj_add_event_cb(pauseBtn, onPauseClicked, LV_EVENT_CLICKED, this);
@@ -182,7 +188,7 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
// Sound toggle button
lv_obj_t* soundBtn = lv_btn_create(btnsWrapper);
lv_obj_set_size(soundBtn, btnSize, btnSize);
lv_obj_set_size(soundBtn, toolbar_height - icon_padding, toolbar_height - icon_padding);
lv_obj_set_style_pad_all(soundBtn, 0, LV_STATE_DEFAULT);
lv_obj_align(soundBtn, LV_ALIGN_CENTER, 0, 0);
lv_obj_add_event_cb(soundBtn, onSoundToggled, LV_EVENT_CLICKED, this);
@@ -333,9 +339,9 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
lv_obj_add_event_cb(gameArea, onPressed, LV_EVENT_PRESSING, this);
lv_obj_add_event_cb(gameArea, onClicked, LV_EVENT_SHORT_CLICKED, this);
lv_obj_add_event_cb(gameArea, onKey, LV_EVENT_KEY, this);
lv_obj_add_event_cb(gameArea, onFocused, LV_EVENT_FOCUSED, this);
lv_obj_add_event_cb(gameArea, onReenterKeyMode, LV_EVENT_CLICKED, this);
// Keyboard focus
// Keyboard focus - explicit enter/exit, no focus/defocus handlers
lv_group_t* group = lv_group_get_default();
if (group) {
lv_group_add_obj(group, gameArea);
@@ -352,6 +358,11 @@ void Breakout::onHide(AppHandle appHandle) {
lv_timer_delete(gameTimer);
gameTimer = nullptr;
}
if (gameArea) {
lv_group_t* group = lv_group_get_default();
if (group) lv_group_set_editing(group, false);
lv_group_remove_obj(gameArea);
}
gameArea = nullptr;
paddle = nullptr;
for (int i = 0; i < MAX_BRICKS; i++) bricks[i] = nullptr;
@@ -1454,6 +1465,16 @@ void Breakout::onKey(lv_event_t* e) {
}
}
break;
case LV_KEY_ESC:
case 'q':
case 'Q': {
// Exit key/edit mode - remove from group so navigation is restored
// Re-entry: tap/click the game area to return to key mode
lv_group_t* group = lv_group_get_default();
if (group) lv_group_set_editing(group, false);
lv_group_remove_obj(lv_event_get_current_target_obj(e));
break;
}
}
}
@@ -1472,7 +1493,13 @@ void Breakout::onSoundToggled(lv_event_t* e) {
self->updateSoundIcon();
}
void Breakout::onFocused(lv_event_t* e) {
void Breakout::onReenterKeyMode(lv_event_t* e) {
lv_obj_t* area = lv_event_get_current_target_obj(e);
lv_group_t* group = lv_group_get_default();
if (group) lv_group_set_editing(group, true);
if (!group) return;
if (lv_obj_get_group(area) == NULL) {
lv_group_add_obj(group, area);
}
lv_group_focus_obj(area);
lv_group_set_editing(group, true);
}
+1 -1
View File
@@ -133,7 +133,7 @@ private:
static void onPressed(lv_event_t* e);
static void onClicked(lv_event_t* e);
static void onKey(lv_event_t* e);
static void onFocused(lv_event_t* e);
static void onReenterKeyMode(lv_event_t* e);
static void onPauseClicked(lv_event_t* e);
static void onSoundToggled(lv_event_t* e);
+2 -2
View File
@@ -5,7 +5,7 @@ sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.breakout
versionName=0.1.0
versionCode=1
versionName=0.2.0
versionCode=2
name=Breakout
description=Classic brick-breaking arcade game
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(EpubReader)
tactility_project(EpubReader)
+89
View File
@@ -0,0 +1,89 @@
# Epub Reader
An EPUB and plain-text reader for Tactility devices.
## Features
- **EPUB support** - parses EPUB 2/3 files (OPF/NCX spine, HTML chapter content)
- **Plain-text support** - opens `.txt` files with the same scroll-based reader
- **Table of Contents** - in-app TOC dialog for direct chapter navigation
- **Reading progress** - automatically saves and restores your position per book
- **Scroll-based navigation** - Prev/Next buttons (and tap left/right half of screen) scroll by one viewport height, snapped to a whole line so text never starts mid-line
- **Chapter auto-skip** - image-only or blank chapters (common in EPUBs) are skipped automatically
- **Books shelf** - designate a folder as your library; the shelf lists all books across one level of subdirectories
- **Font rendering** - runtime-loaded Noto Serif fonts (4 weights: regular, italic, bold, bold-italic) in 4 size tiers to match the display resolution
- **Inline styling** - bold and italic runs within paragraphs are rendered correctly
- **Paragraph alignment** - center and right alignment from the EPUB source is preserved
## Screenshots
![alt text](images/read_view_t_deck.png) ![alt text](images/shelf_view_t_deck.png)
![alt text](images/read_view_paper.png) ![alt text](images/shelf_view_paper.png)
## Requirements
- **PSRAM is required.** The epub parser, font binaries, and LVGL allocations all live in PSRAM. The app will show an error and exit on devices without it.
## Supported File Types
| Extension | Notes |
|-----------|-------|
| `.epub` | EPUB 2/3, ZIP-based |
| `.txt` | Plain UTF-8 text |
## Fonts
Pre-built font binaries (Noto Serif) are bundled in `Apps/EpubReader/assets/` and included in the compiled `.app` package automatically - no setup required for most users.
### Generating your own fonts
You can regenerate the fonts (or substitute a different typeface) using the included `gen_fonts.py` script. The app loads files named `font_16pt_*.bin`, `font_20pt_*.bin`, `font_28pt_*.bin`, and `font_36pt_*.bin` from the assets directory - the names are font-family-agnostic, so any compliant set of binaries will work.
**Prerequisites:**
```
npm install -g lv_font_conv
```
**Font files** - edit `gen_fonts.py` to point `FONTS` at your chosen `.ttf` files and place them in `Apps/EpubReader/fonts/`. The default config expects Noto Serif (4 weights):
- `NotoSerif-Medium.ttf` → regular
- `NotoSerif-MediumItalic.ttf` → italic
- `NotoSerif-Bold.ttf` → bold
- `NotoSerif-BoldItalic.ttf` → bold italic
Four weights (regular, italic, bold, bold-italic) are required; all four size tiers (16, 20, 28, 36 pt) are required.
**Run:**
```
python Apps/EpubReader/gen_fonts.py
```
Output is written to `Apps/EpubReader/assets/`.
## Navigation
| Action | Result |
|--------|--------|
| Tap left half of screen | Previous page |
| Tap right half of screen | Next page |
| ⏮ / ⏭ toolbar buttons | Previous / next page |
| ☰ toolbar button | Table of Contents |
| 📁 toolbar button | Switch to file browser |
| Browser → tap folder | Navigate into folder |
| Browser → tap file | Open book |
| Shelf ◀ ▶ buttons | Page through book list |
## Books Folder / Shelf
In the file browser, navigate to any folder and tap the 📁 toolbar button to save it as your books folder. On subsequent launches the app opens directly to the shelf view for that folder, listing books from it and one level of subdirectories.
## Building
```
python tactility.py build
```
Or build, install, and run on a device:
```
python tactility.py bir <device-ip>
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+129
View File
@@ -0,0 +1,129 @@
"""
gen_fonts.py - regenerate all LVGL binary font files for EpubReader.
Prerequisites:
node / npm must be on PATH
npm install -g lv_font_conv
Usage (from any directory):
python Apps/EpubReader/gen_fonts.py
Output: .bin files written to Apps/EpubReader/assets/
Output font filenames are font-family-agnostic: font_<size>pt_<variant>.bin
The app loader expects exactly these names - swap in any 4-weight typeface by
editing the FONTS dict and placing the .ttf files in Apps/EpubReader/fonts/.
Default: Noto Serif (4 weights) https://fonts.google.com/noto/specimen/Noto+Serif
NotoSerif-Medium.ttf → regular
NotoSerif-MediumItalic.ttf → italic
NotoSerif-Bold.ttf → bold
NotoSerif-BoldItalic.ttf → bold italic
"""
import shutil
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
# ── Latin content fonts (4 weights) ───────────────────────────────────────────
FONTS = {
"regular": SCRIPT_DIR / "fonts" / "NotoSerif-Medium.ttf",
"italic": SCRIPT_DIR / "fonts" / "NotoSerif-MediumItalic.ttf",
"bold": SCRIPT_DIR / "fonts" / "NotoSerif-Bold.ttf",
"bold_italic": SCRIPT_DIR / "fonts" / "NotoSerif-BoldItalic.ttf",
}
# ── Unicode ranges ─────────────────────────────────────────────────────────────
# Unicode coverage - adjust if your chosen font supports different blocks:
LATIN_TEXT_RANGES = [
"-r", "0x20-0x7E", # Basic Latin
"-r", "0xA0-0x024F", # Latin-1 Supplement + Latin Extended-A/B
"-r", "0x0370-0x03FF", # Greek (π, Δ, Σ, Ω etc.)
"-r", "0x1E9E", # ẞ (capital sharp-s, German)
"-r", "0x2010-0x2026", # General Punctuation (hyphens, dashes, ellipsis)
"-r", "0x2070-0x209F", # Superscripts and Subscripts (⁰⁴⁵⁶⁷⁸⁹ ₀₁₂₃₄₅₆₇₈₉)
"-r", "0x20A0-0x20CF", # Currency Symbols (€ ₹ ₽ ₩ ₪ ₴ etc.)
"-r", "0x2100-0x214F", # Letterlike Symbols (™ ℃ ℉ etc.)
"-r", "0x2150-0x218F", # Number Forms (⅓ ⅔ ⅛ ⅜ ⅝ ⅞ etc.)
]
# ── Sizes to generate ──────────────────────────────────────────────────────────
SIZES = [16, 20, 28, 36]
# ── Rendering options ──────────────────────────────────────────────────────────
BPP = 2 # 2-bit anti-aliasing; use 4 for smoother rendering (~2× larger)
FORMAT = "bin" # "lvgl" = C struct output; "bin" = binary blob
COMPRESS = False # True = enable RLE compression (smaller binary, slightly slower)
# ── Output directory ───────────────────────────────────────────────────────────
OUT_DIR = SCRIPT_DIR / "assets"
OUT_DIR.mkdir(parents=True, exist_ok=True)
# ── Locate lv_font_conv (handles Windows .cmd npm wrappers) ───────────────────
def _find_tool() -> str:
for name in ("lv_font_conv", "lv_font_conv.cmd"):
found = shutil.which(name)
if found:
return found
print("ERROR: lv_font_conv not found. Install with: npm install -g lv_font_conv", file=sys.stderr)
sys.exit(1)
LV_FONT_CONV = _find_tool()
USE_SHELL = sys.platform == "win32"
def _run(cmd: list) -> bool:
if USE_SHELL:
result = subprocess.run(subprocess.list2cmdline(cmd), capture_output=True, text=True, shell=True)
else:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" ERROR: {result.stderr.strip()}", file=sys.stderr)
return False
return True
def generate_font(font_path: Path, ranges: list, size: int, symbol: str) -> bool:
"""Convert a single .ttf to an LVGL binary font file."""
if not font_path.exists():
print(f" SKIP {symbol} (font not found: {font_path.name})")
return False
out = OUT_DIR / f"{symbol}.bin"
cmd = [LV_FONT_CONV,
"--font", str(font_path)] + ranges + [
"--size", str(size),
"--bpp", str(BPP),
"--format", FORMAT,
"--lv-font-name", symbol,
"-o", str(out),
]
if not COMPRESS:
cmd.append("--no-compress")
print(f" {symbol:<36} size={size}{font_path.name}")
return _run(cmd)
def main():
print(f"lv_font_conv → {OUT_DIR}\n")
generated = 0
skipped = 0
# ── Content font variants (Latin text + symbols) ───────────────────────────
for size in SIZES:
print(f"── {size}pt {'' * 52}")
for font_key in FONTS.keys():
symbol = f"font_{size}pt_{font_key}"
ok = generate_font(FONTS[font_key], LATIN_TEXT_RANGES, size, symbol)
if ok: generated += 1
else: skipped += 1
print(f"\nDone. {generated} generated, {skipped} skipped.")
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+11
View File
@@ -0,0 +1,11 @@
file(GLOB_RECURSE SOURCE_FILES
Source/*.c*
)
idf_component_register(
SRCS ${SOURCE_FILES}
# Library headers must be included directly,
# because all regular dependencies get stripped by elf_loader's cmake script
INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include
REQUIRES TactilitySDK esp_rom
)
+349
View File
@@ -0,0 +1,349 @@
#include "EpubReader.h"
#include "HtmlStrip.h" // stripHtmlToText
#include <tt_bundle.h>
#include <tt_lvgl_toolbar.h>
#include <tt_app_alertdialog.h>
#include <tt_app_selectiondialog.h>
#include <tactility/log.h>
#include <esp_heap_caps.h>
#include <sys/stat.h>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <vector>
static const char* TAG = "EpubReader";
static const char* EPUB_FILE_ARGUMENT = "file";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
std::string EpubReader::getAppDataRoot(AppHandle app) {
char path[128] = {0};
size_t sz = sizeof(path);
tt_app_get_user_data_path(app, path, &sz);
// path = /sdcard/user/app/one.tactility.epubreader → extract "/sdcard"
// path = /data/user/app/one.tactility.epubreader → extract "/data"
std::string s(path);
size_t pos = s.find('/', 1);
return (pos != std::string::npos) ? s.substr(0, pos) : "/sdcard";
}
// ---------------------------------------------------------------------------
// Books folder persistence
// ---------------------------------------------------------------------------
void EpubReader::loadBooksPath() {
if (!appHandle_) return;
char path[128]; size_t sz = sizeof(path);
tt_app_get_user_data_child_path(appHandle_, "books_folder.txt", path, &sz);
FILE* f = fopen(path, "r");
if (!f) return;
char buf[256] = {};
if (fgets(buf, sizeof(buf), f)) {
size_t len = strlen(buf);
if (len > 0 && buf[len - 1] == '\n') buf[len - 1] = '\0';
if (buf[0] != '\0') booksPath_ = buf;
}
fclose(f);
}
void EpubReader::saveBooksPath() {
if (!appHandle_) return;
char path[128]; size_t sz = sizeof(path);
tt_app_get_user_data_child_path(appHandle_, "books_folder.txt", path, &sz);
FILE* f = fopen(path, "w");
if (!f) return;
fprintf(f, "%s\n", booksPath_.c_str());
fclose(f);
}
// ---------------------------------------------------------------------------
// File type helpers (static members - used by UI and Async TUs via the class)
// ---------------------------------------------------------------------------
bool EpubReader::isSupportedFile(const std::string& name) {
auto pos = name.rfind('.');
if (pos == std::string::npos) return false;
std::string ext = name.substr(pos);
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
return ext == ".epub" || ext == ".txt";
}
bool EpubReader::isTextFile(const std::string& path) {
auto pos = path.rfind('.');
if (pos == std::string::npos) return false;
std::string ext = path.substr(pos);
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
return ext == ".txt";
}
// ---------------------------------------------------------------------------
// Persistence - plain text file in the app user-data directory
// ---------------------------------------------------------------------------
void EpubReader::saveProgress() {
if (currentFilePath_.empty() || !appHandle_) return;
char path[128]; size_t sz = sizeof(path);
tt_app_get_user_data_child_path(appHandle_, "progress.txt", path, &sz);
FILE* f = fopen(path, "w");
if (!f) return;
// Text mode: pageOffset_ is a scroll-Y pixel position; epub: spine chapter index.
// The mode tag makes the file self-describing so the value's semantics are unambiguous.
int savedIndex = textMode_ ? (int)pageOffset_ : currentSpineIndex_;
fprintf(f, "%s\n%d\n%s\n", currentFilePath_.c_str(), savedIndex,
textMode_ ? "text" : "epub");
fclose(f);
}
bool EpubReader::loadProgress(std::string& outPath, int& outChapter, bool& outIsText) {
if (!appHandle_) return false;
char path[128]; size_t sz = sizeof(path);
tt_app_get_user_data_child_path(appHandle_, "progress.txt", path, &sz);
FILE* f = fopen(path, "r");
if (!f) return false;
char filePath[256] = {};
bool ok = (fgets(filePath, sizeof(filePath), f) != nullptr);
int chapter = 0;
char modeStr[8] = "epub"; // default for backward-compat with old progress files
if (ok) {
size_t len = strlen(filePath);
if (len > 0 && filePath[len - 1] == '\n') filePath[len - 1] = '\0';
if (fscanf(f, "%d", &chapter) != 1) chapter = 0;
fscanf(f, " %7s", modeStr); // optional - old files won't have it
}
fclose(f);
if (!ok || filePath[0] == '\0') return false;
outPath = filePath;
outChapter = chapter;
outIsText = (strcmp(modeStr, "text") == 0);
return true;
}
// ---------------------------------------------------------------------------
// Chapter loading and pagination
// ---------------------------------------------------------------------------
// Render all of pageContent_ as per-paragraph labels, then restore saved scroll position.
// Prev/Next navigate by scrolling one viewport height within the chapter; at chapter
// boundaries they load the adjacent chapter (like text mode, but across chapters).
void EpubReader::renderPage() {
if (!contentWidget_) return;
lv_obj_clean(contentWidget_);
if (pageContent_.empty()) {
lv_obj_t* lbl = lv_label_create(contentWidget_);
lv_label_set_text(lbl, "(Empty chapter)");
lv_obj_scroll_to_y(lv_obj_get_parent(contentWidget_), 0, LV_ANIM_OFF);
return;
}
renderSlice(pageContent_);
lv_coord_t scrollY = (pageOffset_ > (size_t)LV_COORD_MAX) ? LV_COORD_MAX : (lv_coord_t)pageOffset_;
lv_obj_scroll_to_y(lv_obj_get_parent(contentWidget_), scrollY, LV_ANIM_OFF);
}
void EpubReader::loadChapter(int index, int direction) {
if (!epub_ || !contentWidget_) return;
const auto& spine = epub_->getSpine();
// Auto-skip chapters whose HTML strips to nothing (image-only, boilerplate, etc.)
// currentSpineIndex_ is NOT updated until we confirm the chapter has content -
// if we exhaust the spine before finding any, we return with it unchanged so
// subsequent navigation calls aren't confused by a corrupted index.
while (true) {
if (index < 0 || index >= (int)spine.size()) return;
std::string html = epub_->readFile(spine[index].href, MAX_CHAPTER_HTML);
stripHtmlToText(html, pageContent_);
html = {}; // release raw HTML before rendering
if (!pageContent_.empty()) break;
// Chapter stripped to nothing - advance in the navigation direction
if (direction == 0) {
currentSpineIndex_ = index;
pageOffset_ = 0;
LOG_W(TAG, "Chapter %d stripped to nothing and no direction to skip - blank chapter", index);
lv_obj_clean(contentWidget_);
lv_obj_t* lbl = lv_label_create(contentWidget_);
lv_label_set_text(lbl, "(Chapter content unavailable)");
return;
}
index += direction;
}
currentSpineIndex_ = index;
pageOffset_ = 0;
renderPage();
// When arriving from a later chapter (going backward), jump to the end of this chapter
// after LVGL has laid out the labels (deferred so content height is known).
if (direction < 0) lv_async_call(asyncScrollToEnd, this);
saveProgress();
}
void EpubReader::openTocDialog() {
if (!epub_) return;
const auto& toc = epub_->getToc();
if (toc.empty()) return;
std::vector<const char*> titles;
titles.reserve(toc.size());
for (const auto& item : toc) {
titles.push_back(item.title.c_str());
}
tocDialogId_ = tt_app_selectiondialog_start(
"Table of Contents", (int)titles.size(), titles.data()
);
}
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
void EpubReader::onShow(AppHandle app, lv_obj_t* parent) {
appHandle_ = app;
// Hard requirement: without PSRAM this app cannot parse EPUBs or hold fonts.
// Show a one-shot alert then stop; psramAlertId_ prevents re-launching the dialog
// if onShow is called again before tt_app_stop() takes effect.
if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM) == 0) {
if (psramAlertId_ == 0) {
static const char* kButtons[] = { "OK" };
psramAlertId_ = tt_app_alertdialog_start(
"PSRAM Required",
"Epub Reader requires a device with PSRAM and cannot run on this hardware.",
kButtons, 1
);
}
return;
}
loadFonts();
if (dataRoot_.empty()) {
dataRoot_ = getAppDataRoot(app);
// Ensure the app data directory exists (needed for progress.txt and books_folder.txt)
char dir[128]; size_t sz = sizeof(dir);
tt_app_get_user_data_path(app, dir, &sz);
for (char* p = dir + 1; *p; ++p) {
if (*p == '/') { *p = '\0'; mkdir(dir, 0755); *p = '/'; }
}
mkdir(dir, 0755);
// Load saved books folder; start browser there if set, otherwise dataRoot_
loadBooksPath();
browsePath_ = booksPath_.empty() ? dataRoot_ : booksPath_;
}
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
toolbar_ = tt_lvgl_toolbar_create_for_app(parent, app);
wrapperWidget_ = lv_obj_create(parent);
lv_obj_set_width(wrapperWidget_, LV_PCT(100));
lv_obj_set_flex_grow(wrapperWidget_, 1);
lv_obj_set_flex_flow(wrapperWidget_, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(wrapperWidget_, 0, 0);
lv_obj_set_style_border_width(wrapperWidget_, 0, 0);
lv_obj_set_style_bg_opa(wrapperWidget_, LV_OPA_TRANSP, 0);
lv_obj_remove_flag(wrapperWidget_, LV_OBJ_FLAG_SCROLLABLE);
// Apply chapter jump from TOC dialog (stored in onResult)
if (pendingChapterIndex_ >= 0) {
currentSpineIndex_ = pendingChapterIndex_;
pendingChapterIndex_ = -1;
}
// Attempt to open a book: launch parameter → saved progress (first show only).
// Both paths use lv_async_call so EpubService::open runs at fresh stack
// depth (not nested inside the deep GuiService→onShow call chain).
bool asyncOpen = false;
if (!epub_) {
BundleHandle bundle = tt_app_get_parameters(app);
if (bundle) {
char filepath[256] = {0};
if (tt_bundle_opt_string(bundle, EPUB_FILE_ARGUMENT, filepath, (uint32_t)sizeof(filepath))) {
LOG_I(TAG, "Opening from parameter: %s", filepath);
pendingFilePath_ = filepath;
lv_async_call(asyncOpenEpub, this);
asyncOpen = true;
}
}
}
bool asyncRestore = false;
if (!epub_ && !asyncOpen) {
std::string savedPath;
int savedChapter = 0;
bool savedIsText = false;
if (loadProgress(savedPath, savedChapter, savedIsText)) {
// Schedule open via lv_async_call so it runs at the same call-stack
// depth as asyncOpenEpub - calling EpubService::open directly here
// (from GuiService→onShow) adds extra frames that overflow the task stack.
pendingFilePath_ = savedPath;
currentSpineIndex_ = savedChapter;
lv_async_call(asyncRestoreEpub, this);
asyncRestore = true;
LOG_I(TAG, "Scheduling restore: %s ch%d", savedPath.c_str(), savedChapter);
}
}
if (epub_ && epub_->isValid()) {
setReaderToolbarButtons();
buildReaderUI(wrapperWidget_);
} else if (!asyncRestore && !asyncOpen) {
epub_ = nullptr;
setBrowserToolbarButtons();
buildBrowserUI(wrapperWidget_);
} else {
// asyncOpenEpub / asyncRestoreEpub will replace the wrapper contents when
// they fire; show an empty placeholder for now (no browser flash on ePaper)
}
}
void EpubReader::onHide(AppHandle /*app*/) {
++openToken_; // invalidate any in-flight background open task
// Skip font unload during a dialog roundtrip (tocDialogId_ is non-zero while
// the TOC selection dialog is open). loadFonts() guards against double-loading,
// so fonts will be reused as-is when onShow fires after the dialog closes.
// When truly exiting, no dialog is open and fonts are freed as normal.
if (tocDialogId_ == 0 && psramAlertId_ == 0) {
unloadFonts();
}
contentWidget_ = nullptr;
wrapperWidget_ = nullptr;
toolbar_ = nullptr;
appHandle_ = nullptr;
pageContent_ = {}; // release chapter/text memory
pageOffset_ = 0;
textMode_ = false;
}
void EpubReader::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId,
AppResult /*result*/, BundleHandle resultData) {
// Never touch LVGL objects here - store state for onShow
if (launchId == psramAlertId_ && psramAlertId_ != 0) {
tt_app_stop();
return;
}
if (launchId == tocDialogId_ && tocDialogId_ != 0) {
tocDialogId_ = 0;
if (resultData) {
int32_t selection = tt_app_selectiondialog_get_result_index(resultData);
if (selection >= 0 && epub_) {
const auto& toc = epub_->getToc();
const auto& spine = epub_->getSpine();
if (selection < (int32_t)toc.size()) {
pendingChapterIndex_ = -1; // Reset before searching
for (size_t i = 0; i < spine.size(); ++i) {
if (spine[i].href == toc[selection].href) {
pendingChapterIndex_ = (int)i;
break;
}
}
}
}
}
}
}
+125
View File
@@ -0,0 +1,125 @@
#pragma once
#include <TactilityCpp/App.h>
#include <tt_app.h>
#include <lvgl.h>
#include <atomic>
#include <memory>
#include <string>
#include <vector>
#include "EpubService.h"
// Icon fonts (compiled in; small and device-independent)
LV_FONT_DECLARE(material_symbols_shared_20)
LV_FONT_DECLARE(material_symbols_shared_32)
// Font selection utility (defined in EpubReaderUI.cpp, shared across translation units).
const lv_font_t* selectContentFont(bool italic = false, bool bold = false);
class EpubReader final : public App {
// App handle (stored for use in async rebuilds where AppHandle isn't available)
AppHandle appHandle_ = nullptr;
static constexpr size_t MAX_CHAPTER_HTML = 131072; // max HTML bytes read per chapter (128 KB)
// EPUB / text reader state
std::shared_ptr<EpubService> epub_;
bool textMode_ = false; // true when showing a plain .txt file (no epub_)
std::string currentFilePath_;
int currentSpineIndex_ = 0;
AppLaunchId tocDialogId_ = 0;
AppLaunchId psramAlertId_ = 0; // Non-zero once the no-PSRAM alert has been launched
int pendingChapterIndex_ = -1; // Set by onResult, consumed in onShow
// Per-chapter stripped plain text + current display offset
std::string pageContent_;
size_t pageOffset_ = 0;
// File browser state
std::string dataRoot_;
std::string browsePath_;
std::string pendingFilePath_; // Set before async open
std::vector<std::pair<std::string, bool>> browserEntries_; // {name, isDir}
// Books folder
std::string booksPath_; // saved books folder path (empty = not set)
int shelfPage_ = 0; // current page in shelf view (persists across open/close)
// Background open token - incremented on each new open + in onHide to invalidate
// any in-flight background task so its result is discarded if it arrives late.
std::atomic<uint32_t> openToken_ = 0;
// UI pointers (nulled in onHide)
lv_obj_t* toolbar_ = nullptr;
lv_obj_t* wrapperWidget_ = nullptr;
// In text mode: an lv_label.
// In EPUB mode: a transparent flex-column container holding per-paragraph labels.
lv_obj_t* contentWidget_ = nullptr;
// Font lifecycle - loads the 4 Noto Serif variants for the active display size tier
// from binary assets via lv_binfont_create; unloaded in onHide (skipped during dialog roundtrips).
void loadFonts();
void unloadFonts();
// UI builders
void buildReaderUI(lv_obj_t* parent);
// Parse an ESC-encoded page slice and populate contentWidget_ with labels.
void renderSlice(const std::string& slice);
void buildBrowserUI(lv_obj_t* parent);
void buildShelfUI(lv_obj_t* parent);
void setReaderToolbarButtons();
void setBrowserToolbarButtons();
// Chapter / text logic
void loadChapter(int index, int direction = 1);
void renderPage();
void saveProgress();
bool loadProgress(std::string& outPath, int& outChapter, bool& outIsText);
void openTocDialog();
// Helpers
static std::string getAppDataRoot(AppHandle app);
void loadBooksPath();
void saveBooksPath();
static bool isSupportedFile(const std::string& name);
static bool isTextFile(const std::string& path);
void scanBooksDir(const std::string& path, const std::string& prefix); // shelf flat-scan helper
// Async rebuilds - safe to call from within LVGL event callbacks
static void asyncOpenEpub(void* data);
static void asyncRestoreEpub(void* data); // like asyncOpenEpub but keeps currentSpineIndex_
static void asyncNavigateBrowser(void* data);
static void asyncSwitchToBrowser(void* data);
// Background open - runs EpubService::open off the LVGL task to avoid stack overflow.
// asyncOpenComplete is posted via lv_async_call when done.
static void spawnOpenTask(EpubReader* self, bool restore); // shared setup for both open paths
static void backgroundOpenTask(void* data); // xTaskCreate target
static void asyncOpenComplete(void* data); // lv_async_call target, back on LVGL task
static void asyncScrollToEnd(void* data); // lv_async_call: scroll to bottom after layout
// Navigation - shared by toolbar callbacks and tap zones
void doPrev();
void doNext();
// LVGL event callbacks
static void onPrevPressed(lv_event_t* e);
static void onNextPressed(lv_event_t* e);
static void onReaderTap(lv_event_t* e);
static void onTocPressed(lv_event_t* e);
static void onBrowsePressed(lv_event_t* e);
static void onBrowserBack(lv_event_t* e);
static void onBrowserItem(lv_event_t* e);
static void onSetBooksFolder(lv_event_t* e); // "Folder" toolbar button
static void onShelfFirst(lv_event_t* e);
static void onShelfPrev(lv_event_t* e);
static void onShelfNext(lv_event_t* e);
static void onShelfLast(lv_event_t* e);
public:
void onShow(AppHandle context, lv_obj_t* parent) override;
void onHide(AppHandle context) override;
void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override;
};
@@ -0,0 +1,370 @@
#include "EpubReader.h"
#include <tt_lvgl_toolbar.h>
#include <tt_lock.h>
#include <tactility/log.h>
#include <Tactility/kernel/Kernel.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/idf_additions.h>
#include <climits>
#include <cstdio>
#include <esp_heap_caps.h>
static const char* TAG = "EpubReader";
// ---------------------------------------------------------------------------
// Background open arguments - allocated on heap, owned by the background task,
// freed in asyncOpenComplete (or in spawnOpenTask on task-create failure).
// ---------------------------------------------------------------------------
struct OpenArgs {
EpubReader* self;
std::string filePath; // path to .epub or .txt
bool restore; // true = keep currentSpineIndex_ (restore mode)
int spineIndex; // savedChapter / savedOffset for restore
uint32_t token; // matches self->openToken_ at dispatch time
// Results filled by backgroundOpenTask:
std::shared_ptr<EpubService> epub; // null on failure
std::string textContent; // pre-read content for .txt files
};
// ---------------------------------------------------------------------------
// Async rebuilds (deferred - safe to trigger from within LVGL callbacks)
// ---------------------------------------------------------------------------
// Helper: allocate an OpenArgs, clean the wrapper, show a placeholder, and spawn
// the background task. Used by both asyncOpenEpub and asyncRestoreEpub.
void EpubReader::spawnOpenTask(EpubReader* self, bool restore) {
auto* args = new OpenArgs{};
args->self = self;
args->filePath = self->pendingFilePath_;
args->restore = restore;
args->spineIndex = self->currentSpineIndex_;
args->token = self->openToken_;
// Show a brief placeholder so old content doesn't linger during the open
lv_obj_clean(self->wrapperWidget_);
tt_lvgl_toolbar_clear_actions(self->toolbar_);
lv_obj_t* lbl = lv_label_create(self->wrapperWidget_);
lv_obj_set_style_pad_all(lbl, 8, 0);
lv_label_set_text(lbl, restore ? "Loading..." : "Opening...");
if (xTaskCreateWithCaps(EpubReader::backgroundOpenTask, "epubOpen", 32768 /* 32 KB */, args, 3, nullptr, MALLOC_CAP_SPIRAM)
!= pdPASS) {
LOG_E(TAG, "Failed to create open task - out of memory");
delete args;
self->epub_ = nullptr;
self->setBrowserToolbarButtons();
lv_obj_clean(self->wrapperWidget_);
self->buildBrowserUI(self->wrapperWidget_);
}
}
// Like asyncOpenEpub but keeps currentSpineIndex_ - used when restoring a saved session.
void EpubReader::asyncRestoreEpub(void* data) {
auto* self = static_cast<EpubReader*>(data);
if (!self->wrapperWidget_ || !self->toolbar_) return;
self->textMode_ = false;
++self->openToken_;
spawnOpenTask(self, /*restore=*/true);
}
void EpubReader::asyncNavigateBrowser(void* data) {
auto* self = static_cast<EpubReader*>(data);
if (!self->wrapperWidget_ || !self->toolbar_) return;
self->setBrowserToolbarButtons();
lv_obj_clean(self->wrapperWidget_);
self->buildBrowserUI(self->wrapperWidget_);
}
void EpubReader::asyncOpenEpub(void* data) {
auto* self = static_cast<EpubReader*>(data);
if (!self->wrapperWidget_ || !self->toolbar_) return;
self->currentSpineIndex_ = 0;
self->textMode_ = false;
++self->openToken_;
spawnOpenTask(self, /*restore=*/false);
}
void EpubReader::asyncSwitchToBrowser(void* data) {
auto* self = static_cast<EpubReader*>(data);
if (!self->wrapperWidget_ || !self->toolbar_) return;
self->epub_ = nullptr;
self->textMode_ = false;
self->currentSpineIndex_ = 0;
self->contentWidget_ = nullptr;
// Return to books folder (if set) so the user lands on their library
if (!self->booksPath_.empty()) self->browsePath_ = self->booksPath_;
self->setBrowserToolbarButtons();
lv_obj_clean(self->wrapperWidget_);
self->buildBrowserUI(self->wrapperWidget_);
}
// ---------------------------------------------------------------------------
// Background open task + completion callback
// ---------------------------------------------------------------------------
// Runs on a FreeRTOS task with its own 32 KB stack.
// Does all SD card I/O (epub parse or text file read) completely off the LVGL
// task to prevent stack overflow and serialise SDMMC access.
void EpubReader::backgroundOpenTask(void* data) {
auto* a = static_cast<OpenArgs*>(data);
// Acquire the filesystem lock before any SD card I/O - prevents concurrent
// SDMMC access from the background and LVGL tasks (bus errors 0x107/0x108).
auto lock = tt_lock_alloc_for_path(a->filePath.c_str());
if (!tt_lock_acquire(lock, tt::kernel::MAX_TICKS)) {
LOG_E(TAG, "FS lock timed out, skipping open: %s", a->filePath.c_str());
tt_lock_free(lock);
lv_async_call(asyncOpenComplete, a);
vTaskDelete(nullptr);
return;
}
if (isTextFile(a->filePath)) {
// Read the entire text file here (under the lock) so asyncOpenComplete
// only needs to update UI state - no SD I/O on the LVGL task.
FILE* f = fopen(a->filePath.c_str(), "r");
if (f) {
char buf[512];
while (a->textContent.size() < MAX_CHAPTER_HTML) {
size_t remaining = MAX_CHAPTER_HTML - a->textContent.size();
size_t toRead = remaining < sizeof(buf) ? remaining : sizeof(buf);
size_t n = fread(buf, 1, toRead, f);
if (n == 0) break;
a->textContent.append(buf, n);
}
fclose(f);
} else {
LOG_E(TAG, "Cannot open text file: %s", a->filePath.c_str());
}
} else {
// Open the epub (ZIP directory scan + OPF/NCX XML parse)
a->epub = EpubService::open(a->filePath);
}
tt_lock_release(lock);
tt_lock_free(lock);
// Signal the LVGL task that the work is done
lv_async_call(asyncOpenComplete, a);
vTaskDelete(nullptr);
}
// Called back on the LVGL task (via lv_async_call from backgroundOpenTask).
// Checks the open token, then either builds the reader UI or falls back to browser.
void EpubReader::asyncOpenComplete(void* data) {
auto* a = static_cast<OpenArgs*>(data);
auto* self = a->self;
// Discard stale results if the app was hidden or a newer open was started
if (!self->wrapperWidget_ || !self->toolbar_ || a->token != self->openToken_) {
delete a;
return;
}
if (isTextFile(a->filePath)) {
self->epub_ = nullptr;
self->textMode_ = false;
self->currentSpineIndex_ = a->restore ? a->spineIndex : 0;
if (!a->textContent.empty()) {
// Content was pre-read in backgroundOpenTask (under the FS lock) -
// no SD I/O needed here on the LVGL task.
self->pageContent_ = std::move(a->textContent);
self->textMode_ = true;
self->currentFilePath_ = a->filePath;
self->pageOffset_ = (self->currentSpineIndex_ > 0)
? (size_t)self->currentSpineIndex_ : 0u;
self->currentSpineIndex_ = 0;
LOG_I(TAG, "Text file loaded: %zu bytes", self->pageContent_.size());
} else {
// Text content empty (lock timeout or read error) - show error in browser
LOG_E(TAG, "Text content empty; cannot display: %s", a->filePath.c_str());
lv_obj_clean(self->wrapperWidget_);
lv_obj_t* errLbl = lv_label_create(self->wrapperWidget_);
lv_obj_set_style_pad_all(errLbl, 8, 0);
lv_label_set_text(errLbl, "Failed to open file.\nPlease try again.");
self->setBrowserToolbarButtons();
delete a;
return;
}
self->setReaderToolbarButtons();
lv_obj_clean(self->wrapperWidget_);
self->buildReaderUI(self->wrapperWidget_);
delete a;
return;
}
if (a->epub && a->epub->isValid()) {
self->epub_ = a->epub;
self->currentFilePath_ = a->filePath;
self->setReaderToolbarButtons();
lv_obj_clean(self->wrapperWidget_);
self->buildReaderUI(self->wrapperWidget_);
} else {
LOG_E(TAG, "Failed to open: %s", a->filePath.c_str());
self->epub_ = nullptr;
self->currentSpineIndex_ = 0;
self->setBrowserToolbarButtons();
lv_obj_clean(self->wrapperWidget_);
self->buildBrowserUI(self->wrapperWidget_);
}
delete a;
}
// ---------------------------------------------------------------------------
// LVGL event callbacks
// ---------------------------------------------------------------------------
// Fired via lv_async_call after renderPage() when loading a chapter backward (direction < 0).
// By the time this runs LVGL has completed layout, so content height is known and we can
// scroll to the very end - placing the user at the bottom of the chapter they backed into.
void EpubReader::asyncScrollToEnd(void* data) {
auto* self = static_cast<EpubReader*>(data);
if (!self->contentWidget_ || !self->wrapperWidget_) return;
lv_obj_t* scroll = lv_obj_get_parent(self->contentWidget_);
if (!scroll) return;
lv_obj_scroll_to_y(scroll, LV_COORD_MAX, LV_ANIM_OFF);
lv_coord_t sy = lv_obj_get_scroll_y(scroll);
self->pageOffset_ = (sy > 0) ? (size_t)sy : 0;
self->saveProgress();
}
// Snap a scroll step down to the nearest whole-line multiple so page turns
// always land on a clean line boundary (no partial lines at top or bottom).
// Uses the actual LVGL font line_height rather than hardcoded estimates.
static lv_coord_t snapStep(lv_coord_t viewH) {
lv_coord_t lineH = (lv_coord_t)lv_font_get_line_height(selectContentFont());
lv_coord_t step = (lineH > 0) ? (viewH / lineH) * lineH : viewH;
return (step > 0) ? step : viewH; // fallback: scroll full height if tiny display
}
// Both text and EPUB modes use the same scroll-by-viewport-height approach.
// snapStep ensures each page turn is a whole-line multiple, so - provided all
// paragraph label Y positions are also multiples of lineH (zero label padding +
// pad_row=lineH on contentWidget_) - pages always start on a clean line boundary.
// At chapter boundaries (EPUB only) the adjacent chapter is loaded.
void EpubReader::doPrev() {
if (!contentWidget_) return;
lv_obj_t* scroll = lv_obj_get_parent(contentWidget_);
if (!scroll) return;
lv_coord_t curY = lv_obj_get_scroll_y(scroll);
lv_coord_t step = snapStep(lv_obj_get_height(scroll));
lv_obj_scroll_to_y(scroll, curY > step ? curY - step : 0, LV_ANIM_OFF);
lv_coord_t newY = lv_obj_get_scroll_y(scroll);
if (newY == curY && !textMode_) {
// Scroll didn't move - already at the top; cross into the previous chapter.
if (currentSpineIndex_ > 0) loadChapter(currentSpineIndex_ - 1, -1);
} else {
pageOffset_ = (newY > 0) ? (size_t)newY : 0;
saveProgress();
}
}
void EpubReader::doNext() {
if (!contentWidget_) return;
lv_obj_t* scroll = lv_obj_get_parent(contentWidget_);
if (!scroll) return;
lv_coord_t curY = lv_obj_get_scroll_y(scroll);
lv_coord_t step = snapStep(lv_obj_get_height(scroll));
lv_obj_scroll_to_y(scroll, curY + step, LV_ANIM_OFF);
lv_coord_t newY = lv_obj_get_scroll_y(scroll);
if (newY == curY && !textMode_) {
// Scroll position didn't move - content fits in the viewport or we've reached
// the end. lv_obj_get_scroll_bottom() returns negative for short chapters so
// checking == 0 is unreliable; this approach works for all chapter lengths.
loadChapter(currentSpineIndex_ + 1, +1);
} else {
pageOffset_ = (newY > 0) ? (size_t)newY : 0;
saveProgress();
}
}
void EpubReader::onPrevPressed(lv_event_t* e) {
static_cast<EpubReader*>(lv_event_get_user_data(e))->doPrev();
}
void EpubReader::onNextPressed(lv_event_t* e) {
static_cast<EpubReader*>(lv_event_get_user_data(e))->doNext();
}
void EpubReader::onReaderTap(lv_event_t* e) {
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
lv_indev_t* indev = lv_indev_active();
if (!indev) return;
lv_point_t pt;
lv_indev_get_point(indev, &pt);
lv_coord_t w = lv_display_get_horizontal_resolution(nullptr);
if (pt.x < w / 2) self->doPrev();
else self->doNext();
}
void EpubReader::onTocPressed(lv_event_t* e) {
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
self->openTocDialog();
}
void EpubReader::onBrowsePressed(lv_event_t* e) {
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
lv_async_call(asyncSwitchToBrowser, self);
}
void EpubReader::onBrowserBack(lv_event_t* e) {
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
size_t pos = self->browsePath_.rfind('/');
if (pos != std::string::npos && self->browsePath_ != self->dataRoot_) {
self->browsePath_ = self->browsePath_.substr(0, pos);
}
lv_async_call(asyncNavigateBrowser, self);
}
void EpubReader::onBrowserItem(lv_event_t* e) {
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
uintptr_t idx = (uintptr_t)lv_obj_get_user_data(lv_event_get_target_obj(e));
if (idx >= self->browserEntries_.size()) return;
const auto& [name, isDir] = self->browserEntries_[idx];
if (isDir) {
self->browsePath_ += "/" + name;
lv_async_call(asyncNavigateBrowser, self);
} else {
self->pendingFilePath_ = self->browsePath_ + "/" + name;
lv_async_call(asyncOpenEpub, self);
}
}
// "Use Folder" toolbar button - save the current browsePath_ as the books folder.
void EpubReader::onSetBooksFolder(lv_event_t* e) {
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
self->booksPath_ = self->browsePath_;
self->saveBooksPath();
lv_async_call(asyncNavigateBrowser, self);
}
// Shelf page navigation callbacks
void EpubReader::onShelfFirst(lv_event_t* e) {
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
self->shelfPage_ = 0;
lv_async_call(asyncNavigateBrowser, self);
}
void EpubReader::onShelfPrev(lv_event_t* e) {
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
if (self->shelfPage_ > 0) --self->shelfPage_;
lv_async_call(asyncNavigateBrowser, self);
}
void EpubReader::onShelfNext(lv_event_t* e) {
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
++self->shelfPage_; // clamped in buildShelfUI
lv_async_call(asyncNavigateBrowser, self);
}
void EpubReader::onShelfLast(lv_event_t* e) {
auto* self = static_cast<EpubReader*>(lv_event_get_user_data(e));
self->shelfPage_ = INT_MAX; // clamped to totalPages-1 in buildShelfUI
lv_async_call(asyncNavigateBrowser, self);
}
@@ -0,0 +1,541 @@
#include "EpubReader.h"
#include <tt_lvgl_toolbar.h>
#include <tactility/log.h>
#include <esp_heap_caps.h>
#include <dirent.h>
#include <sys/stat.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <vector>
static const char* TAG = "EpubReader";
// Runtime-loaded Noto Serif fonts - 4 variants for the active display size tier.
// Loaded by EpubReader::loadFonts() on onShow, freed by unloadFonts() on onHide
// (skipped when onHide fires for a dialog roundtrip - tocDialogId_ will be non-zero).
// lv_binfont_create/destroy are exported by the firmware's lvgl-module symbols.
// NOTE: These are file-scope statics intentionally. Tactility runs one instance of
// each app at a time, so there is no multi-instance aliasing or use-after-free risk.
static lv_font_t* s_fontRegular = nullptr;
static lv_font_t* s_fontItalic = nullptr;
static lv_font_t* s_fontBold = nullptr;
static lv_font_t* s_fontBoldItalic = nullptr;
#define LVGL_SYMBOL_BOOK "\xEF\x94\xBE" // U+F53E (MaterialSymbols: book_2)
#define LVGL_SYMBOL_TEXT "\xEF\x87\x86" // U+F1C6 (MaterialSymbols: text_snippet)
// lv_list_add_btn places the icon image at child 0 and the text label at child 1.
// Centralise that assumption here so there's one place to update if LVGL changes.
static void setListBtnLongMode(lv_obj_t* btn, lv_label_long_mode_t mode) {
lv_obj_t* lbl = lv_obj_get_child(btn, 1);
if (lbl) lv_label_set_long_mode(lbl, mode);
}
// ---------------------------------------------------------------------------
// Toolbar helper
// ---------------------------------------------------------------------------
void EpubReader::setReaderToolbarButtons() {
tt_lvgl_toolbar_clear_actions(toolbar_);
tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_PREV, onPrevPressed, this);
if (!textMode_) {
tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_LIST, onTocPressed, this);
}
tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_NEXT, onNextPressed, this);
tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onBrowsePressed, this);
}
void EpubReader::setBrowserToolbarButtons() {
tt_lvgl_toolbar_clear_actions(toolbar_);
// Show "Use Folder" button when the current browse path isn't already the saved books folder
if (browsePath_ != booksPath_) {
tt_lvgl_toolbar_add_text_button_action(toolbar_, LV_SYMBOL_DIRECTORY, onSetBooksFolder, this);
}
}
// ---------------------------------------------------------------------------
// UI builders
// ---------------------------------------------------------------------------
static const lv_font_t* selectIconFont() {
lv_coord_t w = lv_display_get_horizontal_resolution(nullptr);
lv_coord_t h = lv_display_get_vertical_resolution(nullptr);
bool isLarge = w >= 480 || h >= 320;
return isLarge ? &material_symbols_shared_32 : &material_symbols_shared_20;
}
const lv_font_t* selectContentFont(bool italic, bool bold) {
const lv_font_t* f = bold ? (italic ? s_fontBoldItalic : s_fontBold)
: (italic ? s_fontItalic : s_fontRegular);
if (f) return f;
// Variant not loaded (e.g. no-PSRAM device only loads regular) - fall back gracefully.
return s_fontRegular ? s_fontRegular : lv_font_get_default();
}
// ---------------------------------------------------------------------------
// Font lifecycle
// ---------------------------------------------------------------------------
void EpubReader::loadFonts() {
if (s_fontRegular) return; // already loaded
// Without PSRAM the heap is too constrained to hold even a single binary font
// alongside the epub parser and LVGL allocations - skip loading entirely.
// onShow() will have already shown an alert dialog for this case.
if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM) == 0) return;
// Verify the assets directory exists before attempting individual file loads.
// This produces one clear diagnostic instead of one error per font file.
char assetsDir[256]; size_t dirSz = sizeof(assetsDir);
tt_app_get_assets_path(appHandle_, assetsDir, &dirSz);
if (dirSz == 0) {
LOG_E(TAG, "loadFonts: could not resolve assets path");
return;
}
struct stat st;
if (stat(assetsDir, &st) != 0 || !S_ISDIR(st.st_mode)) {
LOG_W(TAG, "loadFonts: assets directory not found: %s", assetsDir);
return;
}
lv_coord_t w = lv_display_get_horizontal_resolution(nullptr);
lv_coord_t h = lv_display_get_vertical_resolution(nullptr);
const char* sz;
if (w >= 1000 || h >= 1000) sz = "36";
else if (w >= 600 || h >= 480 ) sz = "28";
else if (w >= 400 || h >= 300 ) sz = "20";
else sz = "16";
struct Variant { const char* suffix; lv_font_t** ptr; };
Variant variants[] = {
{ "regular", &s_fontRegular },
{ "italic", &s_fontItalic },
{ "bold", &s_fontBold },
{ "bold_italic", &s_fontBoldItalic },
};
for (auto& v : variants) {
char filename[64];
snprintf(filename, sizeof(filename), "font_%spt_%s.bin", sz, v.suffix);
char assetPath[256]; size_t pathSz = sizeof(assetPath);
tt_app_get_assets_child_path(appHandle_, filename, assetPath, &pathSz);
if (pathSz == 0) {
LOG_E(TAG, "loadFonts: no asset path for %s", filename);
continue;
}
// LVGL STDIO driver letter 'A' with empty path prefix: "A:/foo" → fopen("/foo")
std::string lvglPath = std::string("A:") + assetPath;
*v.ptr = lv_binfont_create(lvglPath.c_str());
if (!*v.ptr) LOG_E(TAG, "lv_binfont_create failed: %s", assetPath);
else LOG_I(TAG, "Font loaded: %s (%dpt)", v.suffix, atoi(sz));
}
}
void EpubReader::unloadFonts() {
lv_font_t** ptrs[] = { &s_fontRegular, &s_fontItalic, &s_fontBold, &s_fontBoldItalic };
for (auto* p : ptrs) {
if (*p) { lv_binfont_destroy(*p); *p = nullptr; }
}
}
void EpubReader::buildReaderUI(lv_obj_t* parent) {
lv_obj_t* scroll = lv_obj_create(parent);
lv_obj_set_width(scroll, LV_PCT(100));
lv_obj_set_flex_grow(scroll, 1);
lv_obj_set_style_pad_all(scroll, 6, 0);
lv_obj_set_style_border_width(scroll, 0, 0);
// Tap left half = prev, right half = next.
// LV_EVENT_CLICKED only fires on press+release with minimal movement,
// so it won't conflict with vertical scrolling in text mode.
lv_obj_add_event_cb(scroll, onReaderTap, LV_EVENT_CLICKED, this);
if (textMode_) {
// Plain text file: single label, Prev/Next scroll by screen height.
contentWidget_ = lv_label_create(scroll);
lv_obj_set_width(contentWidget_, LV_PCT(100));
lv_label_set_long_mode(contentWidget_, LV_LABEL_LONG_MODE_WRAP);
lv_obj_set_style_text_font(contentWidget_, selectContentFont(false, false), 0);
lv_label_set_text(contentWidget_, pageContent_.c_str());
lv_obj_scroll_to_y(scroll, (lv_coord_t)pageOffset_, LV_ANIM_OFF);
saveProgress();
} else if (epub_) {
// EPUB: transparent flex-column container; renderPage() fills it with
// per-paragraph labels (one per paragraph, with individual alignment).
contentWidget_ = lv_obj_create(scroll);
lv_obj_set_width(contentWidget_, LV_PCT(100));
lv_obj_set_height(contentWidget_, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(contentWidget_, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(contentWidget_, 0, 0);
// One line-height of gap between paragraphs keeps all paragraph tops on
// exact lineH-multiple boundaries - required for snapStep to work cleanly.
lv_obj_set_style_pad_row(contentWidget_,
(lv_coord_t)lv_font_get_line_height(selectContentFont(false, false)), 0);
lv_obj_set_style_border_width(contentWidget_, 0, 0);
lv_obj_set_style_bg_opa(contentWidget_, LV_OPA_TRANSP, 0);
lv_obj_remove_flag(contentWidget_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(contentWidget_, LV_OBJ_FLAG_CLICKABLE);
loadChapter(currentSpineIndex_, +1);
}
}
// ---------------------------------------------------------------------------
// renderSlice helpers - inline bold/italic via lv_spangroup
// ---------------------------------------------------------------------------
// Returns true if the text segment contains any inline bold/italic ESC tokens.
static bool hasInlineTokens(const char* s, size_t len) {
for (size_t i = 0; i + 1 < len; ++i) {
if ((unsigned char)s[i] == 0x1B) {
char t = s[i + 1];
if (t == 'B' || t == 'b' || t == 'I' || t == 'i') return true;
}
}
return false;
}
// Builds an lv_spangroup for a paragraph containing inline bold/italic ESC tokens.
// Each ESC+B/b/I/i boundary becomes a new span with the appropriate font.
static void renderSpanParagraph(lv_obj_t* parent, const char* seg, size_t len,
lv_text_align_t align) {
lv_obj_t* sg = lv_spangroup_create(parent);
lv_obj_set_width(sg, LV_PCT(100));
lv_obj_set_height(sg, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(sg, 0, 0); // keep heights = N*lineH (same rule as lv_label)
lv_spangroup_set_align(sg, align);
lv_spangroup_set_mode(sg, LV_SPAN_MODE_BREAK); // wrap to content height
lv_obj_remove_flag(sg, LV_OBJ_FLAG_CLICKABLE);
bool bold = false, italic = false;
std::string run;
run.reserve(len);
auto flushRun = [&]() {
if (run.empty()) return;
lv_span_t* span = lv_spangroup_add_span(sg);
lv_span_set_text(span, run.c_str());
lv_style_set_text_font(lv_span_get_style(span), selectContentFont(italic, bold));
run.clear();
};
for (size_t i = 0; i < len; ++i) {
unsigned char c = (unsigned char)seg[i];
if (c == 0x1B && i + 1 < len) {
char tok = seg[i + 1];
if (tok == 'B') { flushRun(); bold = true; ++i; continue; }
if (tok == 'b') { flushRun(); bold = false; ++i; continue; }
if (tok == 'I') { flushRun(); italic = true; ++i; continue; }
if (tok == 'i') { flushRun(); italic = false; ++i; continue; }
}
run += (char)c;
}
flushRun();
lv_spangroup_refresh(sg);
}
// ---------------------------------------------------------------------------
// renderSlice - parse ESC-encoded text into per-paragraph LVGL widgets
// ---------------------------------------------------------------------------
// ESC token protocol (see HtmlStrip.h):
// Every paragraph starts with ESC + 'L'|'C'|'R' (alignment).
// Paragraphs are separated by '\n\n'.
// Single '\n' = line break within a paragraph.
// Inline ESC+'B'/'b'/'I'/'i' tokens delimit bold/italic runs.
//
// Paragraphs without inline tokens → lv_label (lighter, fewer allocations).
// Paragraphs with inline tokens → lv_spangroup (per-span font selection).
void EpubReader::renderSlice(const std::string& slice) {
if (!contentWidget_) return;
const lv_font_t* font = selectContentFont(false, false);
size_t pos = 0;
const size_t len = slice.size();
while (pos < len) {
// Find the next paragraph boundary (\n\n) or end of string
size_t nlnl = slice.find("\n\n", pos);
size_t paraEnd = (nlnl != std::string::npos) ? nlnl : len;
// Extract alignment from leading ESC token
lv_text_align_t align = LV_TEXT_ALIGN_LEFT;
size_t textStart = pos;
if (paraEnd > pos + 1 && (unsigned char)slice[pos] == 0x1B) {
char code = slice[pos + 1];
if (code == 'C') align = LV_TEXT_ALIGN_CENTER;
else if (code == 'R') align = LV_TEXT_ALIGN_RIGHT;
textStart = pos + 2;
}
const char* seg = slice.data() + textStart;
size_t segLen = paraEnd - textStart;
// Skip blank paragraphs
bool hasContent = false;
for (size_t k = 0; k < segLen; ++k) {
char ch = seg[k];
if (ch != ' ' && ch != '\n') { hasContent = true; break; }
}
if (hasContent) {
if (hasInlineTokens(seg, segLen)) {
// Mixed-style paragraph: spangroup renders inline bold/italic runs
renderSpanParagraph(contentWidget_, seg, segLen, align);
} else {
// Plain paragraph: lv_label (simpler, lower memory overhead)
lv_obj_t* lbl = lv_label_create(contentWidget_);
lv_obj_set_width(lbl, LV_PCT(100));
lv_obj_set_style_pad_all(lbl, 0, 0); // override theme padding; keeps heights = N*lineH
lv_label_set_long_mode(lbl, LV_LABEL_LONG_MODE_WRAP);
lv_obj_set_style_text_font(lbl, font, 0);
lv_obj_set_style_text_align(lbl, align, 0);
lv_label_set_text(lbl, std::string(seg, segLen).c_str());
}
}
pos = (nlnl != std::string::npos) ? nlnl + 2 : len;
}
}
void EpubReader::buildBrowserUI(lv_obj_t* parent) {
// When at the configured books folder, show the shelf instead of the file list
if (!booksPath_.empty() && browsePath_ == booksPath_) {
buildShelfUI(parent);
return;
}
// Path bar
lv_obj_t* pathBar = lv_obj_create(parent);
lv_obj_set_size(pathBar, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(pathBar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(pathBar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(pathBar, 4, 0);
lv_obj_set_style_pad_gap(pathBar, 4, 0);
lv_obj_set_style_border_width(pathBar, 0, 0);
lv_obj_set_style_bg_opa(pathBar, LV_OPA_TRANSP, 0);
lv_obj_remove_flag(pathBar, LV_OBJ_FLAG_SCROLLABLE);
if (browsePath_ != dataRoot_) {
lv_obj_t* backBtn = lv_button_create(pathBar);
lv_obj_set_size(backBtn, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(backBtn, 4, 0);
lv_label_set_text(lv_label_create(backBtn), LV_SYMBOL_LEFT " Back");
lv_obj_add_event_cb(backBtn, onBrowserBack, LV_EVENT_CLICKED, this);
}
lv_obj_t* pathLabel = lv_label_create(pathBar);
lv_obj_set_flex_grow(pathLabel, 1);
lv_label_set_text(pathLabel, browsePath_.c_str());
lv_label_set_long_mode(pathLabel, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR
// File list
lv_obj_t* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_style_border_width(list, 0, 0);
// Scan directory
browserEntries_.clear();
DIR* dir = opendir(browsePath_.c_str());
if (dir) {
struct dirent* entry;
while ((entry = readdir(dir)) != nullptr) {
if (entry->d_name[0] == '.') continue;
std::string name(entry->d_name);
bool isDir = (entry->d_type == DT_DIR);
if (entry->d_type == DT_UNKNOWN) {
struct stat st;
std::string fullPath = browsePath_ + "/" + name;
if (stat(fullPath.c_str(), &st) == 0) {
isDir = S_ISDIR(st.st_mode);
}
}
if (isDir || isSupportedFile(name)) {
browserEntries_.push_back({name, isDir});
}
}
closedir(dir);
} else {
LOG_W(TAG, "Cannot open dir: %s", browsePath_.c_str());
}
// Sort: directories first, then alphabetically within each group
std::sort(browserEntries_.begin(), browserEntries_.end(),
[](const auto& a, const auto& b) {
if (a.second != b.second) return a.second > b.second;
return a.first < b.first;
});
if (browserEntries_.empty()) {
lv_list_add_text(list, "No supported files found.");
} else {
for (size_t i = 0; i < browserEntries_.size(); ++i) {
const auto& [name, isDir] = browserEntries_[i];
const char* icon = isDir ? LV_SYMBOL_DIRECTORY : LV_SYMBOL_FILE;
lv_obj_t* btn = lv_list_add_button(list, icon, name.c_str());
lv_obj_set_user_data(btn, (void*)(uintptr_t)i);
lv_obj_add_event_cb(btn, onBrowserItem, LV_EVENT_CLICKED, this);
setListBtnLongMode(btn, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR
}
}
}
// Recursively scans path for epub/txt files, appending entries to browserEntries_.
// Called with prefix="" for booksPath_; recurses one level into subdirectories.
void EpubReader::scanBooksDir(const std::string& path, const std::string& prefix) {
DIR* d = opendir(path.c_str());
if (!d) return;
struct dirent* ent;
while ((ent = readdir(d)) != nullptr) {
if (ent->d_name[0] == '.') continue;
std::string name(ent->d_name);
bool isDir = (ent->d_type == DT_DIR);
if (ent->d_type == DT_UNKNOWN) {
struct stat st;
if (stat((path + "/" + name).c_str(), &st) == 0)
isDir = S_ISDIR(st.st_mode);
}
if (isDir && prefix.empty()) {
scanBooksDir(path + "/" + name, name + "/");
} else if (!isDir) {
auto p = name.rfind('.');
if (p == std::string::npos) continue;
std::string e = name.substr(p);
std::transform(e.begin(), e.end(), e.begin(), ::tolower);
if (e == ".epub" || e == ".txt")
browserEntries_.push_back({prefix + name, false});
}
}
closedir(d);
}
// Books shelf view - shown when browsePath_ == booksPath_.
// Scans booksPath_ and one level of subdirectories into a single flat sorted list.
void EpubReader::buildShelfUI(lv_obj_t* parent) {
lv_coord_t dispW = lv_display_get_horizontal_resolution(nullptr);
lv_coord_t dispH = lv_display_get_vertical_resolution(nullptr);
bool isLarge = (dispW >= 480 || dispH >= 480);
bool isXLarge = (dispW >= 600 || dispH >= 600);
int pageSize = isXLarge ? 8 : (isLarge ? 6 : 4);
int padBar = isLarge ? 4 : 2;
int navPadHor = isLarge ? 12 : 6;
int navPadVer = isLarge ? 6 : 3;
// Optional path bar with Back so the user can navigate away from the shelf
if (booksPath_ != dataRoot_) {
lv_obj_t* pathBar = lv_obj_create(parent);
lv_obj_set_size(pathBar, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(pathBar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(pathBar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(pathBar, padBar, 0);
lv_obj_set_style_pad_gap(pathBar, padBar, 0);
lv_obj_set_style_border_width(pathBar, 0, 0);
lv_obj_set_style_bg_opa(pathBar, LV_OPA_TRANSP, 0);
lv_obj_remove_flag(pathBar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* backBtn = lv_button_create(pathBar);
lv_obj_set_size(backBtn, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(backBtn, padBar, 0);
lv_label_set_text(lv_label_create(backBtn), LV_SYMBOL_LEFT " Browse");
lv_obj_add_event_cb(backBtn, onBrowserBack, LV_EVENT_CLICKED, this);
lv_obj_t* pathLbl = lv_label_create(pathBar);
lv_obj_set_flex_grow(pathLbl, 1);
lv_label_set_long_mode(pathLbl, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR
lv_label_set_text(pathLbl, booksPath_.c_str());
}
// Scan booksPath_ for books, and one level of subdirectories into a flat list.
// Subfolder entries are stored as "subfolder/filename" so onBrowserItem builds
// the correct full path via browsePath_ + "/" + name.
browserEntries_.clear();
scanBooksDir(booksPath_, "");
// Sort alphabetically by filename (ignoring subfolder prefix)
std::sort(browserEntries_.begin(), browserEntries_.end(),
[](const auto& a, const auto& b) {
// rfind returns npos if no '/' found; npos+1 wraps to 0, yielding the full string
auto nameA = a.first.substr(a.first.rfind('/') + 1);
auto nameB = b.first.substr(b.first.rfind('/') + 1);
return std::lexicographical_compare(
nameA.begin(), nameA.end(),
nameB.begin(), nameB.end(),
[](char ca, char cb) { return std::tolower((unsigned char)ca) < std::tolower((unsigned char)cb); });
});
// Clamp shelfPage_ to valid range
int totalItems = (int)browserEntries_.size();
int totalPages = std::max(1, (totalItems + pageSize - 1) / pageSize);
shelfPage_ = std::max(0, std::min(shelfPage_, totalPages - 1));
int startIdx = shelfPage_ * pageSize;
int endIdx = std::min(startIdx + pageSize, totalItems);
// Non-scrollable list; items share height equally via flex_grow=1
lv_obj_t* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_style_border_width(list, 0, 0);
lv_obj_remove_flag(list, LV_OBJ_FLAG_SCROLLABLE);
if (browserEntries_.empty()) {
lv_list_add_text(list, "No books found in books folder.");
} else {
for (int i = startIdx; i < endIdx; ++i) {
const std::string& entry = browserEntries_[(size_t)i].first;
// Display name: strip subfolder prefix and extension
std::string displayName = entry.substr(entry.rfind('/') + 1);
auto dot = displayName.rfind('.');
if (dot != std::string::npos) displayName = displayName.substr(0, dot);
auto dotPos = entry.rfind('.');
std::string ext = (dotPos != std::string::npos) ? entry.substr(dotPos) : "";
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
const char* icon = (ext == ".txt") ? LVGL_SYMBOL_TEXT : LVGL_SYMBOL_BOOK;
lv_obj_t* btn = lv_list_add_button(list, icon, displayName.c_str());
lv_obj_set_flex_grow(btn, 1); // equal height share across page items
lv_obj_set_user_data(btn, (void*)(uintptr_t)i);
lv_obj_add_event_cb(btn, onBrowserItem, LV_EVENT_CLICKED, this);
setListBtnLongMode(btn, LV_LABEL_LONG_MODE_DOTS);//LV_LABEL_LONG_MODE_SCROLL_CIRCULAR
lv_obj_t* iconLbl = lv_obj_get_child(btn, 0);
if (iconLbl) lv_obj_set_style_text_font(iconLbl, selectIconFont(), 0);
}
}
// ── Bottom navigation bar ─────────────────────────────────────────────────
lv_obj_t* navBar = lv_obj_create(parent);
lv_obj_set_size(navBar, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(navBar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(navBar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(navBar, padBar, 0);
lv_obj_set_style_pad_gap(navBar, padBar, 0);
lv_obj_set_style_border_width(navBar, 0, 0);
lv_obj_set_style_bg_opa(navBar, LV_OPA_TRANSP, 0);
lv_obj_remove_flag(navBar, LV_OBJ_FLAG_SCROLLABLE);
bool canPrev = (shelfPage_ > 0);
bool canNext = (shelfPage_ < totalPages - 1);
auto makeNavBtn = [&](const char* label, lv_event_cb_t cb, bool enabled) {
lv_obj_t* btn = lv_button_create(navBar);
lv_obj_set_size(btn, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(btn, navPadHor, 0);
lv_obj_set_style_pad_ver(btn, navPadVer, 0);
lv_label_set_text(lv_label_create(btn), label);
if (enabled) lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, this);
else lv_obj_add_state(btn, LV_STATE_DISABLED);
};
makeNavBtn(LV_SYMBOL_PREV, onShelfFirst, canPrev);
makeNavBtn(LV_SYMBOL_LEFT, onShelfPrev, canPrev);
char pageBuf[24];
snprintf(pageBuf, sizeof(pageBuf), "%d / %d", shelfPage_ + 1, totalPages);
lv_obj_t* pageLbl = lv_label_create(navBar);
lv_obj_set_style_pad_hor(pageLbl, 10, 0);
lv_label_set_text(pageLbl, pageBuf);
makeNavBtn(LV_SYMBOL_RIGHT, onShelfNext, canNext);
makeNavBtn(LV_SYMBOL_NEXT, onShelfLast, canNext);
}
+294
View File
@@ -0,0 +1,294 @@
#include "EpubService.h"
#include "SimpleXmlParser.h"
#include <tactility/log.h>
#include <map>
extern "C" {
#include "epub_parser.h"
}
static const char* TAG = "EpubService";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
std::string EpubService::pathParent(const std::string& path) {
size_t pos = path.rfind('/');
return (pos == std::string::npos) ? "" : path.substr(0, pos);
}
// Join basePath + href and resolve any ".." or "." segments so EPUBs that use
// relative paths like "../../Text/chapter1.xhtml" resolve correctly.
static std::string normalizePath(const std::string& basePath, const std::string& href) {
if (!href.empty() && href[0] == '/') return href; // already absolute
std::string combined = basePath.empty() ? href : basePath + "/" + href;
// Walk each slash-delimited segment and resolve ".." / "."
std::string result;
result.reserve(combined.size());
size_t i = 0;
while (i <= combined.size()) {
size_t slash = combined.find('/', i);
bool atEnd = (slash == std::string::npos);
std::string seg = atEnd ? combined.substr(i) : combined.substr(i, slash - i);
if (seg == "..") {
size_t last = result.rfind('/');
if (last != std::string::npos) result.erase(last);
else result.clear();
} else if (!seg.empty() && seg != ".") {
if (!result.empty()) result += '/';
result += seg;
}
if (atEnd) break;
i = slash + 1;
}
return result;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
std::shared_ptr<EpubService> EpubService::open(const std::string& path) {
epub_reader_c* reader = nullptr;
epub_parser_error err = epub_open_c(path.c_str(), &reader);
if (err != EPUB_OK) {
LOG_E(TAG, "Failed to open epub: %s (err=%d)", path.c_str(), (int)err);
return nullptr;
}
auto service = std::shared_ptr<EpubService>(new EpubService());
service->reader_ = reader;
service->parseContainer();
service->parseContentOpf();
// Return nullptr if essential parsing failed
if (service->spine_.empty()) {
LOG_E(TAG, "EPUB initialization failed: no spine items");
return nullptr;
}
return service;
}
EpubService::~EpubService() {
if (reader_) {
epub_close_c(reader_);
reader_ = nullptr;
}
}
std::string EpubService::readFile(const std::string& filename, size_t maxBytes) const {
if (!reader_) return "";
epub_stream_context_c* stream = epub_stream_open_c(reader_, filename.c_str());
if (!stream) {
LOG_W(TAG, "File not found in epub: %s", filename.c_str());
return "";
}
std::string result;
// Small stack buffer - readFile sits deep in the LVGL call chain and 4 KB
// eaten by a local array is enough to cause a stack overflow on some targets.
char buffer[512];
size_t readBytes;
while ((readBytes = epub_stream_read_c(stream, buffer, sizeof(buffer))) > 0) {
if (maxBytes > 0 && result.size() + readBytes > maxBytes) {
result.append(buffer, maxBytes - result.size());
break;
}
result.append(buffer, readBytes);
}
epub_stream_close_c(stream);
// If truncated at maxBytes, ensure we didn't split a UTF-8 multi-byte sequence.
if (maxBytes > 0 && result.size() >= maxBytes) {
size_t len = result.size();
while (len > 0 && ((unsigned char)result[len - 1] & 0xC0) == 0x80) --len;
if (len > 0) {
unsigned char lead = (unsigned char)result[len - 1];
size_t seqLen = (lead < 0x80) ? 1 : (lead < 0xE0) ? 2 : (lead < 0xF0) ? 3 : 4;
if (len - 1 + seqLen > result.size()) result.resize(len - 1);
}
}
return result;
}
std::string EpubService::getMetadata(const std::string& key) const {
auto it = metadata_.find(key);
return (it != metadata_.end()) ? it->second : "";
}
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
void EpubService::parseContainer() {
std::string xml = readFile("META-INF/container.xml", 65536); // 64 KB cap
if (xml.empty()) {
LOG_E(TAG, "container.xml missing");
return;
}
SimpleXmlParser parser;
if (!parser.openFromMemory(xml.c_str(), xml.size())) return;
while (parser.read()) {
if (parser.getNodeType() == SimpleXmlParser::NodeType::Element &&
parser.getName() == "rootfile") {
contentOpfPath_ = parser.getAttribute("full-path");
LOG_I(TAG, "OPF path: %s", contentOpfPath_.c_str());
break;
}
}
}
void EpubService::parseContentOpf() {
if (contentOpfPath_.empty()) return;
std::string xml = readFile(contentOpfPath_, 1048576); // 1 MB cap
if (xml.empty()) {
LOG_E(TAG, "OPF file empty: %s", contentOpfPath_.c_str());
return;
}
SimpleXmlParser parser;
if (!parser.openFromMemory(xml.c_str(), xml.size())) return;
std::map<std::string, std::string> manifest;
bool inMetadata = false, inManifest = false, inSpine = false;
std::string lastTag;
std::string tocId;
std::string coverMetaId; // id from <meta name="cover" content="ID"/>
while (parser.read()) {
auto nodeType = parser.getNodeType();
auto name = parser.getName();
if (nodeType == SimpleXmlParser::NodeType::Element) {
lastTag = name;
if (name == "metadata") {
inMetadata = true;
} else if (name == "manifest") {
inManifest = true;
} else if (name == "spine") {
inSpine = true;
tocId = parser.getAttribute("toc");
} else if (inMetadata && name == "meta") {
// EPUB2: <meta name="cover" content="cover-item-id"/>
if (parser.getAttribute("name") == "cover") {
coverMetaId = parser.getAttribute("content");
}
} else if (inManifest && name == "item") {
std::string id = parser.getAttribute("id");
std::string href = parser.getAttribute("href");
if (!id.empty() && !href.empty()) {
manifest[id] = href;
// EPUB3: <item properties="cover-image" .../>
if (coverPath_.empty()) {
std::string props = parser.getAttribute("properties");
if (props.find("cover-image") != std::string::npos) {
coverPath_ = href; // resolved below after baseDir is known
}
}
// Common fallback ids
if (coverPath_.empty() && (id == "cover" || id == "cover-image" || id == "cover-img")) {
std::string mt = parser.getAttribute("media-type");
if (mt.find("image") != std::string::npos) {
coverPath_ = href;
}
}
}
} else if (inSpine && name == "itemref") {
std::string idref = parser.getAttribute("idref");
if (!idref.empty()) {
spine_.push_back({idref, ""});
}
}
} else if (nodeType == SimpleXmlParser::NodeType::EndElement) {
if (name == "metadata") inMetadata = false;
else if (name == "manifest") inManifest = false;
else if (name == "spine") inSpine = false;
lastTag = "";
} else if (nodeType == SimpleXmlParser::NodeType::Text && inMetadata) {
if (lastTag == "dc:title") metadata_["title"] = parser.getText();
else if (lastTag == "dc:creator") metadata_["creator"] = parser.getText();
}
}
// Resolve spine hrefs from manifest (normalizePath handles "../" segments)
std::string baseDir = pathParent(contentOpfPath_);
for (auto& si : spine_) {
auto it = manifest.find(si.idref);
if (it != manifest.end()) {
si.href = normalizePath(baseDir, it->second);
}
}
// Resolve cover path: EPUB2 <meta name="cover"> takes priority
if (!coverMetaId.empty()) {
auto it = manifest.find(coverMetaId);
if (it != manifest.end()) coverPath_ = it->second;
}
if (!coverPath_.empty()) {
coverPath_ = normalizePath(baseDir, coverPath_);
LOG_I(TAG, "Cover: %s", coverPath_.c_str());
}
LOG_I(TAG, "Spine items: %d", (int)spine_.size());
// Parse NCX table of contents
if (!tocId.empty()) {
auto it = manifest.find(tocId);
if (it != manifest.end()) {
parseTocNcx(normalizePath(baseDir, it->second));
}
}
}
void EpubService::parseTocNcx(const std::string& tocPath) {
std::string xml = readFile(tocPath, 524288); // 512 KB cap
if (xml.empty()) return;
SimpleXmlParser parser;
if (!parser.openFromMemory(xml.c_str(), xml.size())) return;
std::string baseDir = pathParent(tocPath);
bool inNavPoint = false, inNavLabel = false;
std::string currentTitle, currentHref;
while (parser.read()) {
auto nodeType = parser.getNodeType();
auto name = parser.getName();
if (nodeType == SimpleXmlParser::NodeType::Element) {
if (name == "navPoint") {
inNavPoint = true;
currentTitle.clear();
currentHref.clear();
} else if (name == "navLabel") {
inNavLabel = true;
} else if (name == "content" && inNavPoint) {
currentHref = parser.getAttribute("src");
}
} else if (nodeType == SimpleXmlParser::NodeType::Text && inNavLabel) {
currentTitle = parser.getText();
} else if (nodeType == SimpleXmlParser::NodeType::EndElement) {
if (name == "navPoint") {
if (!currentTitle.empty() && !currentHref.empty()) {
// Strip fragment identifier before normalizing
std::string href = currentHref;
size_t hashPos = href.find('#');
if (hashPos != std::string::npos) href = href.substr(0, hashPos);
if (!href.empty()) {
toc_.push_back({currentTitle, normalizePath(baseDir, href)});
}
}
inNavPoint = false;
} else if (name == "navLabel") {
inNavLabel = false;
}
}
}
LOG_I(TAG, "TOC items: %d", (int)toc_.size());
}
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <string>
#include <vector>
#include <map>
#include <memory>
// Forward-declare the C epub reader handle so we don't need to pull in the C header
struct epub_reader_c;
/**
* EpubService - handles opening and reading EPUB archive files.
* Parses the OPF manifest, spine, and NCX table of contents.
* Use open() to create an instance; check isValid() before use.
*/
class EpubService {
public:
struct SpineItem {
std::string idref;
std::string href;
};
struct TocItem {
std::string title;
std::string href;
};
/**
* Open an EPUB file. Returns nullptr on failure.
*/
static std::shared_ptr<EpubService> open(const std::string& path);
~EpubService();
bool isValid() const { return reader_ != nullptr && !spine_.empty(); }
const std::vector<SpineItem>& getSpine() const { return spine_; }
const std::vector<TocItem>& getToc() const { return toc_; }
/** Path of the cover image inside the EPUB archive, or "" if not found.
* Typically a .jpg or .png. Use readFile() to get the raw bytes. */
const std::string& getCoverPath() const { return coverPath_; }
/** Read a file from the EPUB archive into a string. Returns "" on error.
* If maxBytes > 0, reading stops after that many bytes (useful for large chapter HTML). */
std::string readFile(const std::string& filename, size_t maxBytes = 0) const;
/** Get OPF metadata value by key (e.g. "title", "creator"). */
std::string getMetadata(const std::string& key) const;
private:
EpubService() = default;
EpubService(const EpubService&) = delete;
EpubService& operator=(const EpubService&) = delete;
EpubService(EpubService&&) = delete;
EpubService& operator=(EpubService&&) = delete;
epub_reader_c* reader_ = nullptr;
std::vector<SpineItem> spine_;
std::vector<TocItem> toc_;
std::map<std::string, std::string> metadata_;
std::string contentOpfPath_;
std::string coverPath_; // resolved path of cover image within the archive, or ""
void parseContainer();
void parseContentOpf();
void parseTocNcx(const std::string& tocPath);
static std::string pathParent(const std::string& path);
};
+439
View File
@@ -0,0 +1,439 @@
#include "HtmlStrip.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <cstring>
#include <vector>
// ---------------------------------------------------------------------------
// ESC token constants (0x1B = ASCII ESC, safe in UTF-8 text streams)
// ---------------------------------------------------------------------------
static const char ESC = '\x1B';
static const char ALIGN_LEFT = 'L';
static const char ALIGN_CENTER= 'C';
static const char ALIGN_RIGHT = 'R';
static const char BOLD_ON = 'B';
static const char BOLD_OFF = 'b';
static const char ITALIC_ON = 'I';
static const char ITALIC_OFF = 'i';
// ---------------------------------------------------------------------------
// Minimal inline attribute scanner
// Searches the raw attribute string for a named value, e.g.
// findAttrValue("style", "text-align", ...) → "center"
// ---------------------------------------------------------------------------
// Returns true if `haystack` (lower-cased attribute region) contains `needle`.
static bool attrContains(const char* haystack, const char* needle) {
return strstr(haystack, needle) != nullptr;
}
// ---------------------------------------------------------------------------
// Emit a UTF-8 codepoint (up to U+10FFFF) as its byte sequence into `out`.
// ---------------------------------------------------------------------------
static void emitCodepoint(std::string& out, unsigned long cp) {
if (cp < 0x80) {
out += (char)cp;
} else if (cp < 0x800) {
out += (char)(0xC0 | (cp >> 6));
out += (char)(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
out += (char)(0xE0 | (cp >> 12));
out += (char)(0x80 | ((cp >> 6) & 0x3F));
out += (char)(0x80 | (cp & 0x3F));
} else if (cp < 0x110000) {
out += (char)(0xF0 | (cp >> 18));
out += (char)(0x80 | ((cp >> 12) & 0x3F));
out += (char)(0x80 | ((cp >> 6) & 0x3F));
out += (char)(0x80 | (cp & 0x3F));
}
}
// ---------------------------------------------------------------------------
// Main function
// ---------------------------------------------------------------------------
void stripHtmlToText(const std::string& html, std::string& out) {
out.clear();
if (html.empty()) return;
out.reserve(html.size() / 2);
// ── Parser state ────────────────────────────────────────────────────────
bool inTag = false;
bool skipBlock = false;
char tagName[8] = {};
int tagNameLen = 0;
bool tagIsClose = false;
bool tagNameDone = false;
bool tagIsSelfClose = false;
// Attribute buffer - collects text between tag name and '>'
char attrBuf[192] = {};
int attrLen = 0;
// Inline style tracking (nesting depth)
int boldDepth = 0;
int italicDepth = 0;
// Per-span style tracking - a stack so nested spans each close only what they opened.
struct SpanState { bool bold; bool italic; };
std::vector<SpanState> spanStack;
// Per-paragraph alignment (reset at each block element open)
char pendingBlockAlign = ALIGN_LEFT;
int nlPending = 0;
bool lastWasSpace = false;
bool outputEmpty = true;
// Word buffer for hyphenation
std::string wordBuf;
// ── Helpers ─────────────────────────────────────────────────────────────
auto trimTrailingSpace = [&]() {
if (lastWasSpace && !out.empty()) { out.pop_back(); lastWasSpace = false; }
};
auto reqNL = [&](int n) {
if (outputEmpty) return;
trimTrailingSpace();
nlPending = std::max(nlPending, n);
};
auto addNL = [&](int n) {
if (outputEmpty) return;
trimTrailingSpace();
nlPending = std::min(nlPending + n, 2);
};
// Flush any buffered word directly into the output.
auto flushWord = [&]() {
if (wordBuf.empty()) return;
if (!outputEmpty && nlPending > 0) {
for (int k = 0; k < nlPending; ++k) out += '\n';
nlPending = 0;
lastWasSpace = false;
}
if (outputEmpty) {
out += ESC;
out += pendingBlockAlign;
outputEmpty = false;
}
out += wordBuf;
lastWasSpace = false;
wordBuf.clear();
};
// Emit a single ASCII character, with deferred-newline / space-collapse logic.
// Characters that can be part of a word are buffered for hyphenation.
auto emit = [&](char c) {
if (c == ' ') {
flushWord();
if (outputEmpty || lastWasSpace || nlPending > 0) return;
out += ' ';
lastWasSpace = true;
} else if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
wordBuf += c;
} else {
// Non-alphabetic: flush any buffered word first
flushWord();
if (!outputEmpty && nlPending > 0) {
for (int k = 0; k < nlPending; ++k) out += '\n';
nlPending = 0;
lastWasSpace = false;
}
if (outputEmpty) {
out += ESC;
out += pendingBlockAlign;
outputEmpty = false;
}
out += c;
lastWasSpace = false;
}
};
// Emit a raw ESC token (bold/italic on/off) - bypasses word buffering.
auto emitToken = [&](char code) {
flushWord();
if (!outputEmpty) {
out += ESC;
out += code;
}
// If outputEmpty, defer - the token will be emitted after alignment marker
// by the next emit() call. For now, tokens before any content are dropped
// (rare in practice: bold/italic before any paragraph text).
};
// Emit a multi-byte UTF-8 sequence directly (after flushing word and newlines).
auto emitUtf8 = [&](const char* bytes, int len) {
flushWord();
if (!outputEmpty && nlPending > 0) {
for (int k = 0; k < nlPending; ++k) out += '\n';
nlPending = 0;
lastWasSpace = false;
}
if (outputEmpty) {
out += ESC;
out += pendingBlockAlign;
outputEmpty = false;
}
for (int k = 0; k < len; ++k) out += bytes[k];
lastWasSpace = false;
};
// ── Main parse loop ──────────────────────────────────────────────────────
for (size_t i = 0; i < html.size(); ++i) {
unsigned char c = (unsigned char)html[i];
if (c == '<') {
// HTML comment <!-- ... -->
if (i + 3 < html.size() &&
html[i+1]=='!' && html[i+2]=='-' && html[i+3]=='-') {
size_t end = html.find("-->", i + 4);
i = (end != std::string::npos) ? end + 2 : html.size() - 1;
continue;
}
inTag = true;
tagNameLen = 0;
tagIsClose = false;
tagNameDone = false;
tagIsSelfClose = false;
tagName[0] = '\0';
attrLen = 0;
attrBuf[0] = '\0';
} else if (c == '>') {
if (inTag) {
// Lower-case the attribute buffer for case-insensitive checks
for (int k = 0; k < attrLen; ++k)
attrBuf[k] = (char)tolower((unsigned char)attrBuf[k]);
bool isSS = (strcmp(tagName, "style") == 0 ||
strcmp(tagName, "script") == 0 ||
strcmp(tagName, "head") == 0);
bool isP = (strcmp(tagName, "p") == 0);
bool isDiv = (strcmp(tagName, "div") == 0);
bool isBr = (strcmp(tagName, "br") == 0);
bool isLi = (strcmp(tagName, "li") == 0);
bool isH = (tagName[0] == 'h' && tagName[1] >= '1' &&
tagName[1] <= '6' && tagName[2] == '\0');
bool isB = (strcmp(tagName, "b") == 0 ||
strcmp(tagName, "strong") == 0);
bool isI = (strcmp(tagName, "i") == 0 ||
strcmp(tagName, "em") == 0);
bool isSpan= (strcmp(tagName, "span") == 0);
// Determine per-tag alignment and inline style from attributes
char tagAlign = 0; // 0 = inherit / don't change
bool attrBold = attrContains(attrBuf, "font-weight:bold") ||
attrContains(attrBuf, "font-weight: bold");
bool attrItalic = attrContains(attrBuf, "font-style:italic") ||
attrContains(attrBuf, "font-style: italic");
if (attrContains(attrBuf, "text-align:center") ||
attrContains(attrBuf, "text-align: center"))
tagAlign = ALIGN_CENTER;
else if (attrContains(attrBuf, "text-align:right") ||
attrContains(attrBuf, "text-align: right"))
tagAlign = ALIGN_RIGHT;
if (isSS) {
if (!tagIsClose && !tagIsSelfClose) skipBlock = true;
else if (tagIsClose) skipBlock = false;
} else if (!skipBlock) {
if (isBr) {
flushWord();
addNL(1);
} else if (isH && !tagIsClose) {
// h1/h2 → centered, h3-h6 → left
pendingBlockAlign = (tagName[1] <= '2') ? ALIGN_CENTER : ALIGN_LEFT;
if (tagAlign) pendingBlockAlign = tagAlign;
reqNL(2);
} else if (isH && tagIsClose) {
pendingBlockAlign = ALIGN_LEFT;
reqNL(2);
} else if ((isP || isDiv) && !tagIsClose) {
pendingBlockAlign = tagAlign ? tagAlign : ALIGN_LEFT;
reqNL(2);
} else if ((isP || isDiv) && tagIsClose) {
pendingBlockAlign = ALIGN_LEFT;
reqNL(2);
} else if (isLi && !tagIsClose) {
flushWord();
if (!outputEmpty) {
trimTrailingSpace();
int n = std::max(nlPending, 1);
for (int k = 0; k < std::min(n, 2); ++k) out += '\n';
nlPending = 0; lastWasSpace = false;
}
if (outputEmpty) { out += ESC; out += pendingBlockAlign; outputEmpty = false; }
out += '-';
out += ' ';
lastWasSpace = true;
} else if (isB && !tagIsClose) {
boldDepth++;
if (boldDepth == 1) emitToken(BOLD_ON);
} else if (isB && tagIsClose) {
if (boldDepth > 0) {
boldDepth--;
if (boldDepth == 0) emitToken(BOLD_OFF);
}
} else if (isI && !tagIsClose) {
italicDepth++;
if (italicDepth == 1) emitToken(ITALIC_ON);
} else if (isI && tagIsClose) {
if (italicDepth > 0) {
italicDepth--;
if (italicDepth == 0) emitToken(ITALIC_OFF);
}
} else if (isSpan && !tagIsClose) {
SpanState ss = { false, false };
if (attrBold && boldDepth == 0) { boldDepth++; ss.bold = true; emitToken(BOLD_ON); }
if (attrItalic && italicDepth == 0) { italicDepth++; ss.italic = true; emitToken(ITALIC_ON); }
spanStack.push_back(ss);
} else if (isSpan && tagIsClose) {
// Only close styles that this span opened - never touch depth set by <b>/<i>
if (!spanStack.empty()) {
SpanState ss = spanStack.back(); spanStack.pop_back();
if (ss.bold && boldDepth > 0) { boldDepth--; if (boldDepth == 0) emitToken(BOLD_OFF); }
if (ss.italic && italicDepth > 0) { italicDepth--; if (italicDepth == 0) emitToken(ITALIC_OFF); }
}
}
}
}
inTag = false;
} else if (inTag) {
if (!tagNameDone) {
if (tagNameLen == 0 && c == '/') {
tagIsClose = true;
} else if (c == '/') {
tagIsSelfClose = true;
tagNameDone = true;
} else if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
tagNameDone = true;
} else if (tagNameLen < 6) {
tagName[tagNameLen++] = (char)tolower((int)c);
tagName[tagNameLen] = '\0';
} else {
tagNameDone = true;
}
} else {
// Collect attribute content (limited buffer, lower-cased on '>')
if (attrLen < (int)sizeof(attrBuf) - 1) {
attrBuf[attrLen++] = (char)c;
attrBuf[attrLen] = '\0';
}
}
} else if (!skipBlock) {
// ── Text content ────────────────────────────────────────────────
if (c == '\n' || c == '\r' || c == '\t') {
emit(' ');
} else if (c == '&') {
// Entity decoding - output proper Unicode, not ASCII substitutes
if (html.compare(i, 5, "&shy;") == 0) {
i += 4; // soft hyphen - discard
}
else if (html.compare(i, 6, "&nbsp;") == 0) { emit(' '); i += 5; }
else if (html.compare(i, 4, "&lt;") == 0) { emit('<'); i += 3; }
else if (html.compare(i, 4, "&gt;") == 0) { emit('>'); i += 3; }
else if (html.compare(i, 5, "&amp;") == 0) { emit('&'); i += 4; }
else if (html.compare(i, 6, "&quot;") == 0) { emit('"'); i += 5; }
else if (html.compare(i, 6, "&apos;") == 0) { emit('\''); i += 5; }
// Typographic punctuation - emit proper Unicode
else if (html.compare(i, 7, "&mdash;") == 0) {
const char s[] = "\xE2\x80\x94"; emitUtf8(s, 3); i += 6;
}
else if (html.compare(i, 7, "&ndash;") == 0) {
const char s[] = "\xE2\x80\x93"; emitUtf8(s, 3); i += 6;
}
else if (html.compare(i, 8, "&hellip;")== 0) {
const char s[] = "\xE2\x80\xA6"; emitUtf8(s, 3); i += 7;
}
else if (html.compare(i, 7, "&lsquo;") == 0) {
const char s[] = "\xE2\x80\x98"; emitUtf8(s, 3); i += 6;
}
else if (html.compare(i, 7, "&rsquo;") == 0) {
const char s[] = "\xE2\x80\x99"; emitUtf8(s, 3); i += 6;
}
else if (html.compare(i, 7, "&ldquo;") == 0) {
const char s[] = "\xE2\x80\x9C"; emitUtf8(s, 3); i += 6;
}
else if (html.compare(i, 7, "&rdquo;") == 0) {
const char s[] = "\xE2\x80\x9D"; emitUtf8(s, 3); i += 6;
}
else if (html.compare(i, 2, "&#") == 0) {
size_t j = i + 2;
bool isHex = (j < html.size() && (html[j]=='x' || html[j]=='X'));
if (isHex) ++j;
size_t numStart = j;
while (j < html.size() && html[j] != ';') ++j;
if (j < html.size() && j > numStart) {
char numBuf[10] = {};
size_t numLen = std::min(j - numStart, (size_t)(sizeof(numBuf)-1));
html.copy(numBuf, numLen, numStart);
unsigned long cp = isHex ? strtoul(numBuf, nullptr, 16)
: strtoul(numBuf, nullptr, 10);
if (cp == 0x00AD) {
// Soft hyphen - discard
} else if (cp == 0x00A0) {
emit(' ');
} else if (cp >= 0x20 && cp < 0x80) {
emit((char)cp);
} else if (cp >= 0x80) {
// Emit as UTF-8
std::string tmp;
emitCodepoint(tmp, cp);
emitUtf8(tmp.c_str(), (int)tmp.size());
}
i = j;
} else {
emit('&');
}
} else {
emit('&');
}
} else if (c < 0x80) {
emit((char)c);
} else if ((c & 0xE0) == 0xC0) {
// 2-byte UTF-8
bool valid = (i + 1 < html.size() &&
((unsigned char)html[i+1] & 0xC0) == 0x80);
if (valid) {
unsigned char b2 = (unsigned char)html[i+1];
if (c == 0xC2 && b2 == 0xA0) {
emit(' '); // NBSP → regular space
} else if (c == 0xC2 && b2 == 0xAD) {
// U+00AD soft hyphen - discard
} else {
char seq[2] = { (char)c, (char)b2 };
emitUtf8(seq, 2);
}
i += 1;
}
} else if ((c & 0xF0) == 0xE0 && i + 2 < html.size()) {
// 3-byte UTF-8
unsigned char b2 = (unsigned char)html[i+1];
unsigned char b3 = (unsigned char)html[i+2];
bool valid = ((b2 & 0xC0) == 0x80 && (b3 & 0xC0) == 0x80);
if (valid) {
char seq[3] = { (char)c, (char)b2, (char)b3 };
emitUtf8(seq, 3);
i += 2;
}
} else if (c >= 0xF0 && i + 3 < html.size()) {
// 4-byte UTF-8 (emoji etc.) - drop
i += 3;
}
}
}
flushWord();
// Trim trailing whitespace / newlines
while (!out.empty() && (out.back() == '\n' || out.back() == ' '))
out.pop_back();
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <string>
// Strips HTML/XHTML to a plain text + ESC-token string suitable for rendering.
//
// Output format:
// - Every paragraph begins with an alignment token: ESC + 'L'|'C'|'R'
// - Paragraphs are separated by '\n\n'
// - Single '\n' = line break within a paragraph
// - Bold/italic spans are bracketed by ESC tokens (for future span rendering):
// ESC+'B' = bold on, ESC+'b' = bold off
// ESC+'I' = italic on, ESC+'i' = italic off
// - All inline ESC tokens are 2 bytes (ESC + code); renderers that don't
// support inline styles strip them before setting label text.
//
// HTML handling:
// - <h1>/<h2> → center aligned; <h3>-<h6> → left aligned
// - <p>, <div> with style="text-align: center/right" → center/right aligned
// - <b>, <strong>, <span style="font-weight:bold"> → bold on/off tokens
// - <i>, <em>, <span style="font-style:italic"> → italic on/off tokens
// - <br> → '\n'
// - <li> → '\n- ' prefix
// - <style>, <script>, <head> blocks suppressed entirely
// - All HTML entities decoded; multi-byte UTF-8 passed through
// - &shy; → U+00AD soft hyphen (LVGL respects this for line breaking)
// - Typographic entities (&mdash;, &lsquo; etc.) → proper Unicode
// - Liang hyphenation applied to ASCII words (English, with soft hyphens)
void stripHtmlToText(const std::string& html, std::string& out);
@@ -0,0 +1,317 @@
#include "SimpleXmlParser.h"
#include <tactility/log.h>
#include <stdlib.h>
#include <string.h>
static const char* TAG = "SimpleXmlParser";
SimpleXmlParser::SimpleXmlParser() {
buffer_ = (uint8_t*)malloc(BUFFER_SIZE);
if (!buffer_) {
LOG_E(TAG, "Failed to allocate buffer");
abort();
}
}
SimpleXmlParser::~SimpleXmlParser() {
close();
if (buffer_) {
free(buffer_);
buffer_ = nullptr;
}
}
bool SimpleXmlParser::openFromMemory(const char* data, size_t dataSize) {
close();
if (!buffer_ || !data) return false;
memoryData_ = data;
memorySize_ = dataSize;
usingMemory_ = true;
bufferStartPos_ = 0;
bufferLen_ = 0;
filePos_ = 0;
currentNodeType_ = NodeType::None;
return true;
}
bool SimpleXmlParser::openFromStream(StreamCallback callback) {
close();
if (!buffer_ || !callback) return false;
streamCallback_ = callback;
usingStream_ = true;
streamPosition_ = 0;
streamEOF_ = false;
bufferStartPos_ = 0;
bufferLen_ = 0;
filePos_ = 0;
currentNodeType_ = NodeType::None;
return true;
}
void SimpleXmlParser::close() {
memoryData_ = nullptr;
memorySize_ = 0;
usingMemory_ = false;
streamCallback_ = nullptr;
usingStream_ = false;
streamPosition_ = 0;
streamEOF_ = false;
bufferStartPos_ = 0;
bufferLen_ = 0;
filePos_ = 0;
currentNodeType_ = NodeType::None;
}
bool SimpleXmlParser::loadBufferAround(size_t pos) {
if (usingStream_) {
// Simple streaming: we can only move forward
if (pos < bufferStartPos_) return false;
while (pos >= bufferStartPos_ + bufferLen_ && !streamEOF_) {
int bytesRead = streamCallback_((char*)buffer_, BUFFER_SIZE);
if (bytesRead <= 0) {
streamEOF_ = true;
return false;
}
bufferStartPos_ = streamPosition_;
bufferLen_ = bytesRead;
streamPosition_ += bytesRead;
}
return pos >= bufferStartPos_ && pos < bufferStartPos_ + bufferLen_;
}
if (usingMemory_) {
if (pos >= memorySize_) return false;
bufferStartPos_ = pos;
bufferLen_ = (memorySize_ - pos > BUFFER_SIZE) ? BUFFER_SIZE : (memorySize_ - pos);
memcpy(buffer_, memoryData_ + pos, bufferLen_);
return bufferLen_ > 0;
}
return false;
}
char SimpleXmlParser::getByteAt(size_t pos) {
if (usingMemory_) {
return (pos < memorySize_) ? memoryData_[pos] : '\0';
}
if (bufferLen_ > 0 && pos >= bufferStartPos_ && pos < bufferStartPos_ + bufferLen_) {
return (char)buffer_[pos - bufferStartPos_];
}
if (loadBufferAround(pos)) {
return (char)buffer_[pos - bufferStartPos_];
}
return '\0';
}
char SimpleXmlParser::peekChar() {
return getByteAt(filePos_);
}
char SimpleXmlParser::readChar() {
char c = getByteAt(filePos_);
if (c != '\0') filePos_++;
return c;
}
bool SimpleXmlParser::skipWhitespace() {
while (true) {
char c = peekChar();
if (c == '\0') return false;
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
readChar();
} else {
return true;
}
}
}
bool SimpleXmlParser::matchString(const char* str) {
size_t len = strlen(str);
size_t savedFilePos = filePos_;
for (size_t i = 0; i < len; i++) {
if (readChar() != str[i]) {
filePos_ = savedFilePos;
return false;
}
}
return true;
}
bool SimpleXmlParser::read() {
if (!usingMemory_ && !usingStream_) return false;
currentName_.clear();
currentValue_.clear();
isEmptyElement_ = false;
attributes_.clear();
while (true) {
char c = peekChar();
if (c == '\0') {
currentNodeType_ = NodeType::EndOfFile;
return false;
}
if (c == '<') {
readChar(); // consume '<'
char next = peekChar();
if (next == '/') {
return readEndElement();
} else if (next == '!') {
readChar(); // consume '!'
if (peekChar() == '-') return readComment();
if (matchString("[CDATA[")) return readCDATA();
skipToEndOfTag();
continue;
} else if (next == '?') {
return readProcessingInstruction();
} else {
return readElement();
}
} else {
if (readText()) return true;
// whitespace-only text node - continue outer loop
}
}
}
bool SimpleXmlParser::readElement() {
currentNodeType_ = NodeType::Element;
currentName_ = readElementName();
parseAttributes();
skipWhitespace();
if (peekChar() == '/') {
readChar();
isEmptyElement_ = true;
}
skipToEndOfTag();
return true;
}
bool SimpleXmlParser::readEndElement() {
currentNodeType_ = NodeType::EndElement;
readChar(); // consume '/'
currentName_ = readElementName();
skipToEndOfTag();
return true;
}
bool SimpleXmlParser::readText() {
currentNodeType_ = NodeType::Text;
currentValue_.clear();
bool hasNonWhitespace = false;
while (true) {
char c = peekChar();
if (c == '\0' || c == '<') break;
if (c != ' ' && c != '\t' && c != '\n' && c != '\r') hasNonWhitespace = true;
currentValue_ += readChar();
}
return hasNonWhitespace; // false = whitespace-only; caller (read()) continues looping
}
bool SimpleXmlParser::readComment() {
currentNodeType_ = NodeType::Comment;
readChar(); // consume '-'
if (readChar() != '-') {
skipToEndOfTag();
return false;
}
while (true) {
char c = readChar();
if (c == '\0') break;
if (c == '-' && peekChar() == '-') {
readChar();
if (peekChar() == '>') {
readChar();
break;
}
}
}
return true;
}
bool SimpleXmlParser::readCDATA() {
currentNodeType_ = NodeType::CDATA;
while (true) {
char c = readChar();
if (c == '\0') break;
if (c == ']' && peekChar() == ']') {
size_t savedPos = filePos_;
readChar(); // tentatively consume second ']'
if (peekChar() == '>') {
readChar(); // consume '>'
break;
}
filePos_ = savedPos; // not end of CDATA - backtrack second ']'
}
currentValue_ += c;
}
return true;
}
bool SimpleXmlParser::readProcessingInstruction() {
currentNodeType_ = NodeType::ProcessingInstruction;
readChar(); // consume '?'
currentName_ = readElementName();
while (true) {
char c = readChar();
if (c == '\0') break;
if (c == '?' && peekChar() == '>') {
readChar();
break;
}
}
return true;
}
std::string SimpleXmlParser::readElementName() {
std::string name;
while (true) {
char c = peekChar();
if (c == '\0' || c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '>' || c == '/' || c == '=') break;
name += readChar();
}
return name;
}
void SimpleXmlParser::parseAttributes() {
while (true) {
skipWhitespace();
char c = peekChar();
if (c == '>' || c == '/' || c == '\0') break;
std::string name = readElementName();
if (name.empty()) break;
skipWhitespace();
if (readChar() != '=') break;
skipWhitespace();
char quote = readChar();
if (quote != '"' && quote != '\'') break;
std::string value;
while (true) {
char vc = readChar();
if (vc == '\0' || vc == quote) break;
value += vc;
}
attributes_.push_back({name, value});
}
}
void SimpleXmlParser::skipToEndOfTag() {
while (true) {
char c = readChar();
if (c == '>' || c == '\0') break;
}
}
std::string SimpleXmlParser::getAttribute(const char* name) const {
for (const auto& attr : attributes_) {
if (attr.name == name) return attr.value;
}
return "";
}
@@ -0,0 +1,90 @@
#pragma once
#include <string>
#include <vector>
#include <functional>
#include <cstdint>
/**
* SimpleXmlParser - A buffered XML parser for reading attributes
* Adapted from microreader for Tactility.
*/
class SimpleXmlParser {
public:
typedef std::function<int(char* buffer, size_t maxSize)> StreamCallback;
SimpleXmlParser();
~SimpleXmlParser();
SimpleXmlParser(const SimpleXmlParser&) = delete;
SimpleXmlParser& operator=(const SimpleXmlParser&) = delete;
SimpleXmlParser(SimpleXmlParser&&) = delete;
SimpleXmlParser& operator=(SimpleXmlParser&&) = delete;
bool openFromMemory(const char* data, size_t dataSize);
bool openFromStream(StreamCallback callback);
void close();
enum class NodeType {
None = 0,
Element,
Text,
EndElement,
Comment,
ProcessingInstruction,
CDATA,
EndOfFile
};
bool read();
NodeType getNodeType() const { return currentNodeType_; }
const std::string& getName() const { return currentName_; }
bool isEmptyElement() const { return isEmptyElement_; }
std::string getAttribute(const char* name) const;
// For text nodes
const std::string& getText() const { return currentValue_; }
private:
const char* memoryData_ = nullptr;
size_t memorySize_ = 0;
bool usingMemory_ = false;
StreamCallback streamCallback_;
bool usingStream_ = false;
size_t streamPosition_ = 0;
bool streamEOF_ = false;
static constexpr size_t BUFFER_SIZE = 4096;
uint8_t* buffer_ = nullptr;
size_t bufferStartPos_ = 0;
size_t bufferLen_ = 0;
size_t filePos_ = 0;
char getByteAt(size_t pos);
bool loadBufferAround(size_t pos);
bool skipWhitespace();
bool matchString(const char* str);
char readChar();
char peekChar();
struct Attribute {
std::string name;
std::string value;
};
NodeType currentNodeType_ = NodeType::None;
std::string currentName_;
std::string currentValue_;
bool isEmptyElement_ = false;
std::vector<Attribute> attributes_;
bool readElement();
bool readEndElement();
bool readText();
bool readComment();
bool readCDATA();
bool readProcessingInstruction();
void parseAttributes();
std::string readElementName();
void skipToEndOfTag();
};
+298
View File
@@ -0,0 +1,298 @@
#include "epub_parser.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <miniz.h>
/* Prefer PSRAM for large EPUB buffers when available (ESP32 with SPIRAM).
* Falls back to regular malloc so the code compiles on hosts without PSRAM. */
#ifdef CONFIG_SPIRAM
# include <esp_heap_caps.h>
static void* epub_malloc_large(size_t size) {
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
return p ? p : malloc(size);
}
#else
# define epub_malloc_large(sz) malloc(sz)
#endif
#define ZIP_CENTRAL_HEADER_SIG 0x02014b50
#define ZIP_END_CENTRAL_SIG 0x06054b50
#pragma pack(push, 1)
typedef struct {
uint32_t signature;
uint16_t version_made;
uint16_t version_needed;
uint16_t flags;
uint16_t compression;
uint16_t mod_time;
uint16_t mod_date;
uint32_t crc32;
uint32_t compressed_size;
uint32_t uncompressed_size;
uint16_t filename_len;
uint16_t extra_len;
uint16_t comment_len;
uint16_t disk_start;
uint16_t internal_attr;
uint32_t external_attr;
uint32_t local_header_offset;
} zip_central_dir_entry;
typedef struct {
uint32_t signature;
uint16_t disk_num;
uint16_t central_dir_disk;
uint16_t entries_this_disk;
uint16_t total_entries;
uint32_t central_dir_size;
uint32_t central_dir_offset;
uint16_t comment_len;
} zip_end_central_dir;
#pragma pack(pop)
typedef struct {
char* filename;
uint32_t compressed_size;
uint32_t uncompressed_size;
uint32_t local_header_offset;
uint16_t compression;
} file_entry;
struct epub_reader_c {
FILE* fp;
file_entry* files;
uint32_t file_count;
};
/* Stream context holds the fully decompressed file in memory.
* This avoids the multi-call tinfl dictionary problem that occurs when
* pOut_buf_next is reset between calls (losing LZ77 backreference history).
* Peak memory during open = compressed_size + uncompressed_size; after open
* only uncompressed_size is held. */
struct epub_stream_context_c {
uint8_t* data; /* decompressed (or stored) file bytes */
uint32_t size; /* total bytes */
uint32_t pos; /* current read position */
};
/* ---------------------------------------------------------------------------
* Internal helpers
* --------------------------------------------------------------------------- */
static int find_eocd(FILE* fp, zip_end_central_dir* eocd) {
fseek(fp, 0, SEEK_END);
long size = ftell(fp);
if (size < 0) return 0;
long search_range = (size > 1024) ? 1024 : size;
if (search_range < 22) return 0; /* EOCD minimum size */
fseek(fp, -search_range, SEEK_END);
uint8_t* buf = (uint8_t*)malloc((size_t)search_range);
if (!buf) return 0;
size_t n = fread(buf, 1, (size_t)search_range, fp);
if (n < 22) { free(buf); return 0; }
for (int i = (int)n - 22; i >= 0; i--) {
uint32_t sig;
memcpy(&sig, &buf[i], sizeof(sig)); /* avoid unaligned pointer cast UB */
if (sig == ZIP_END_CENTRAL_SIG) {
memcpy(eocd, &buf[i], sizeof(zip_end_central_dir));
free(buf);
return 1;
}
}
free(buf);
return 0;
}
/* Seek fp past the local file header to the first byte of (compressed) data. */
static int seek_to_local_data(FILE* fp, uint32_t local_header_offset) {
if (fseek(fp, (long)local_header_offset, SEEK_SET) != 0) return 0;
uint32_t sig;
if (fread(&sig, 4, 1, fp) != 1) return 0;
/* Skip: version_needed(2) flags(2) compression(2) mod_time(2) mod_date(2)
crc32(4) compressed_size(4) uncompressed_size(4) = 22 bytes */
if (fseek(fp, 22, SEEK_CUR) != 0) return 0;
uint16_t nlen = 0, elen = 0;
if (fread(&nlen, 2, 1, fp) != 1) return 0;
if (fread(&elen, 2, 1, fp) != 1) return 0;
if (fseek(fp, nlen + elen, SEEK_CUR) != 0) return 0;
return 1;
}
/* ---------------------------------------------------------------------------
* Public API
* --------------------------------------------------------------------------- */
epub_parser_error epub_open_c(const char* filepath, epub_reader_c** out_reader) {
if (!filepath || !out_reader) return EPUB_ERROR_INVALID_PARAM;
FILE* fp = fopen(filepath, "rb");
if (!fp) return EPUB_ERROR_FILE_NOT_FOUND;
zip_end_central_dir eocd;
if (!find_eocd(fp, &eocd)) {
fclose(fp);
return EPUB_ERROR_NOT_AN_EPUB;
}
epub_reader_c* reader = (epub_reader_c*)calloc(1, sizeof(epub_reader_c));
if (!reader) { fclose(fp); return EPUB_ERROR_OUT_OF_MEMORY; }
reader->fp = fp;
reader->file_count = eocd.total_entries;
reader->files = (file_entry*)calloc(reader->file_count, sizeof(file_entry));
if (!reader->files) { fclose(fp); free(reader); return EPUB_ERROR_OUT_OF_MEMORY; }
fseek(fp, eocd.central_dir_offset, SEEK_SET);
for (uint32_t i = 0; i < reader->file_count; i++) {
zip_central_dir_entry zcde;
if (fread(&zcde, sizeof(zcde), 1, fp) != 1) {
for (uint32_t j = 0; j < i; j++) free(reader->files[j].filename);
free(reader->files);
fclose(fp);
free(reader);
return EPUB_ERROR_NOT_AN_EPUB;
}
char* name = (char*)malloc(zcde.filename_len + 1u);
if (!name) {
for (uint32_t j = 0; j < i; j++) free(reader->files[j].filename);
free(reader->files);
fclose(fp);
free(reader);
return EPUB_ERROR_OUT_OF_MEMORY;
}
if (fread(name, 1, zcde.filename_len, fp) != zcde.filename_len) {
free(name);
for (uint32_t j = 0; j < i; j++) free(reader->files[j].filename);
free(reader->files);
fclose(fp);
free(reader);
return EPUB_ERROR_NOT_AN_EPUB;
}
name[zcde.filename_len] = '\0';
if (fseek(fp, zcde.extra_len + zcde.comment_len, SEEK_CUR) != 0) {
free(name);
for (uint32_t j = 0; j < i; j++) free(reader->files[j].filename);
free(reader->files);
fclose(fp);
free(reader);
return EPUB_ERROR_NOT_AN_EPUB;
}
reader->files[i].filename = name;
reader->files[i].compressed_size = zcde.compressed_size;
reader->files[i].uncompressed_size = zcde.uncompressed_size;
reader->files[i].local_header_offset = zcde.local_header_offset;
reader->files[i].compression = zcde.compression;
}
*out_reader = reader;
return EPUB_OK;
}
void epub_close_c(epub_reader_c* reader) {
if (!reader) return;
if (reader->fp) fclose(reader->fp);
for (uint32_t i = 0; i < reader->file_count; i++) {
free(reader->files[i].filename);
}
free(reader->files);
free(reader);
}
uint32_t epub_get_file_count_c(epub_reader_c* reader) {
return reader ? reader->file_count : 0;
}
const char* epub_get_filename_c(epub_reader_c* reader, uint32_t index) {
if (!reader || index >= reader->file_count) return NULL;
return reader->files[index].filename;
}
epub_stream_context_c* epub_stream_open_c(epub_reader_c* reader, const char* filename) {
if (!reader || !filename) return NULL;
/* Find the entry */
file_entry* entry = NULL;
for (uint32_t i = 0; i < reader->file_count; i++) {
if (strcmp(reader->files[i].filename, filename) == 0) {
entry = &reader->files[i];
break;
}
}
if (!entry) return NULL;
epub_stream_context_c* ctx = (epub_stream_context_c*)calloc(1, sizeof(epub_stream_context_c));
if (!ctx) return NULL;
if (!seek_to_local_data(reader->fp, entry->local_header_offset)) {
free(ctx);
return NULL;
}
if (entry->compression == 0) {
/* Stored - copy raw bytes directly */
ctx->size = entry->uncompressed_size;
ctx->data = (uint8_t*)epub_malloc_large(ctx->size);
if (!ctx->data) { free(ctx); return NULL; }
if (fread(ctx->data, 1, ctx->size, reader->fp) != ctx->size) {
free(ctx->data); free(ctx); return NULL;
}
} else if (entry->compression == 8) {
/* Deflate - buffer all compressed bytes then decompress in one shot.
* We call tinfl_decompress directly (rather than the _mem_to_mem wrapper)
* so we can place the ~10 KB tinfl_decompressor on the heap instead of
* the stack, which would overflow the LVGL task.
* Both the compressed and decompressed buffers can be hundreds of KB,
* so allocate them in PSRAM when available. */
uint8_t* compressed = (uint8_t*)epub_malloc_large(entry->compressed_size);
if (!compressed) { free(ctx); return NULL; }
if (fread(compressed, 1, entry->compressed_size, reader->fp) != entry->compressed_size) {
free(compressed); free(ctx); return NULL;
}
ctx->size = entry->uncompressed_size;
if (ctx->size > SIZE_MAX - 1) { free(compressed); free(ctx); return NULL; }
ctx->data = (uint8_t*)epub_malloc_large((size_t)ctx->size + 1u); /* +1 so callers can NUL-terminate if needed */
if (!ctx->data) { free(compressed); free(ctx); return NULL; }
/* tinfl_decompressor is ~10 KB - must live on the heap, not the stack,
* or it overflows the LVGL task's stack. */
tinfl_decompressor* decomp = (tinfl_decompressor*)malloc(sizeof(tinfl_decompressor));
if (!decomp) { free(compressed); free(ctx->data); free(ctx); return NULL; }
tinfl_init(decomp);
size_t out_len = ctx->size;
size_t in_len = entry->compressed_size;
tinfl_status status = tinfl_decompress(
decomp,
(const mz_uint8*)compressed, &in_len,
ctx->data, ctx->data, &out_len,
TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF /* single-pass, sequential output */
);
free(decomp);
free(compressed);
if (status != TINFL_STATUS_DONE) {
free(ctx->data); free(ctx); return NULL;
}
ctx->size = (uint32_t)out_len;
} else {
/* Unsupported compression method */
free(ctx);
return NULL;
}
ctx->pos = 0;
return ctx;
}
size_t epub_stream_read_c(epub_stream_context_c* ctx, void* buffer, size_t size) {
if (!ctx || !ctx->data || !buffer || ctx->pos >= ctx->size) return 0;
size_t available = ctx->size - ctx->pos;
size_t to_copy = (available < size) ? available : size;
memcpy(buffer, ctx->data + ctx->pos, to_copy);
ctx->pos += (uint32_t)to_copy;
return to_copy;
}
void epub_stream_close_c(epub_stream_context_c* ctx) {
if (!ctx) return;
free(ctx->data);
free(ctx);
}
+79
View File
@@ -0,0 +1,79 @@
#ifndef EPUB_PARSER_H
#define EPUB_PARSER_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
EPUB_OK = 0,
EPUB_ERROR_FILE_NOT_FOUND, /**< The EPUB file itself could not be opened */
EPUB_ERROR_NOT_AN_EPUB, /**< File exists but is not a valid ZIP/EPUB */
EPUB_ERROR_CORRUPTED, /**< Archive structure is damaged */
EPUB_ERROR_OUT_OF_MEMORY, /**< A memory allocation failed */
EPUB_ERROR_INVALID_PARAM, /**< A required pointer argument was NULL */
EPUB_ERROR_DECOMPRESSION_FAILED, /**< Deflate decompression error */
EPUB_ERROR_INTERNAL_FILE_NOT_FOUND /**< A named file inside the EPUB archive was not found */
} epub_parser_error;
typedef struct epub_reader_c epub_reader_c;
typedef struct epub_stream_context_c epub_stream_context_c;
/**
* Open an EPUB file for reading.
* On success, EPUB_OK is returned and *out_reader is set to a new reader instance.
* The reader must be freed with epub_close_c when no longer needed.
* Returns an error code on failure; *out_reader is not modified.
*/
epub_parser_error epub_open_c(const char* filepath, epub_reader_c** out_reader);
/**
* Close and free an epub_reader_c.
* Safe to call with NULL (no-op).
*/
void epub_close_c(epub_reader_c* reader);
/**
* Return the number of files in the EPUB archive.
* Returns 0 if reader is NULL.
*/
uint32_t epub_get_file_count_c(epub_reader_c* reader);
/**
* Return the filename of the archive entry at the given index.
* The returned string is owned by the reader and is valid until epub_close_c is called.
* Returns NULL if reader is NULL or index >= epub_get_file_count_c(reader).
*/
const char* epub_get_filename_c(epub_reader_c* reader, uint32_t index);
/**
* Open a named file inside the EPUB archive for streaming.
* Returns a stream context on success, or NULL if:
* - reader or filename is NULL
* - the file is not found in the archive
* - a memory allocation fails
* The caller must call epub_stream_close_c when done.
*/
epub_stream_context_c* epub_stream_open_c(epub_reader_c* reader, const char* filename);
/**
* Read up to size bytes from the open stream into buffer.
* Returns the number of bytes actually read.
* Returns 0 at end-of-file or on a decompression error (the two cases are not distinguished).
*/
size_t epub_stream_read_c(epub_stream_context_c* ctx, void* buffer, size_t size);
/**
* Close and free a stream context.
* Safe to call with NULL (no-op).
*/
void epub_stream_close_c(epub_stream_context_c* ctx);
#ifdef __cplusplus
}
#endif
#endif
+11
View File
@@ -0,0 +1,11 @@
#include "EpubReader.h"
#include <TactilityCpp/App.h>
extern "C" {
int main(int argc, char* argv[]) {
registerApp<EpubReader>();
return 0;
}
}
@@ -0,0 +1,133 @@
/*******************************************************************************
* Size: 20 px
* Bpp: 2
* Opts: --no-compress --no-prefilter --bpp 2 --size 20 --font MaterialSymbolsRounded.ttf -r 0xF53E,0xF1C6 --format lvgl -o ..\source-fonts\material_symbols_shared_20.c --force-fast-kern-format
******************************************************************************/
#include "lvgl.h"
#ifndef MATERIAL_SYMBOLS_SHARED_20
#define MATERIAL_SYMBOLS_SHARED_20 1
#endif
#if MATERIAL_SYMBOLS_SHARED_20
/*-----------------
* BITMAPS
*----------------*/
/*Store the image of the glyphs*/
static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
/* U+F1C6 "" */
0x1a, 0xaa, 0xa4, 0x0, 0x7f, 0xff, 0xfe, 0x0,
0xb0, 0x0, 0xb, 0x80, 0xb0, 0x15, 0x42, 0xe0,
0xb0, 0xff, 0xe0, 0xb8, 0xb0, 0x55, 0x40, 0x2d,
0xb0, 0x0, 0x0, 0xe, 0xb0, 0xff, 0xff, 0xe,
0xb0, 0xff, 0xff, 0xe, 0xb0, 0x0, 0x0, 0xe,
0xb0, 0x55, 0x55, 0xe, 0xb0, 0xff, 0xff, 0xe,
0xb0, 0x15, 0x54, 0xe, 0xb0, 0x0, 0x0, 0xe,
0x7f, 0xff, 0xff, 0xfd, 0x1a, 0xaa, 0xaa, 0xa4,
/* U+F53E "" */
0x6, 0xaa, 0xaa, 0x43, 0xff, 0xff, 0xfe, 0x74,
0xe0, 0x0, 0xeb, 0xe, 0x0, 0xe, 0xb0, 0xe0,
0x0, 0xeb, 0xe, 0x0, 0xe, 0xb0, 0xe0, 0x0,
0xeb, 0xe, 0x0, 0xe, 0xb0, 0xe0, 0x0, 0xeb,
0xe, 0x0, 0xe, 0xb0, 0xe0, 0x0, 0xeb, 0x6f,
0xaa, 0xae, 0xbf, 0xff, 0xff, 0xeb, 0x40, 0x0,
0x3c, 0xb0, 0x0, 0x3, 0x87, 0xea, 0xaa, 0xbd,
0x1f, 0xff, 0xff, 0xe0, 0x0, 0x0, 0x0
};
/*---------------------
* GLYPH DESCRIPTION
*--------------------*/
static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
{.bitmap_index = 0, .adv_w = 0, .box_w = 0, .box_h = 0, .ofs_x = 0, .ofs_y = 0} /* id = 0 reserved */,
{.bitmap_index = 0, .adv_w = 320, .box_w = 16, .box_h = 16, .ofs_x = 2, .ofs_y = 2},
{.bitmap_index = 64, .adv_w = 320, .box_w = 14, .box_h = 18, .ofs_x = 3, .ofs_y = 1}
};
/*---------------------
* CHARACTER MAPPING
*--------------------*/
static const uint16_t unicode_list_0[] = {
0x0, 0x378
};
/*Collect the unicode lists and glyph_id offsets*/
static const lv_font_fmt_txt_cmap_t cmaps[] =
{
{
.range_start = 61894, .range_length = 889, .glyph_id_start = 1,
.unicode_list = unicode_list_0, .glyph_id_ofs_list = NULL, .list_length = 2, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY
}
};
/*--------------------
* ALL CUSTOM DATA
*--------------------*/
#if LVGL_VERSION_MAJOR == 8
/*Store all the custom data of the font*/
static lv_font_fmt_txt_glyph_cache_t cache;
#endif
#if LVGL_VERSION_MAJOR >= 8
static const lv_font_fmt_txt_dsc_t font_dsc = {
#else
static lv_font_fmt_txt_dsc_t font_dsc = {
#endif
.glyph_bitmap = glyph_bitmap,
.glyph_dsc = glyph_dsc,
.cmaps = cmaps,
.kern_dsc = NULL,
.kern_scale = 0,
.cmap_num = 1,
.bpp = 2,
.kern_classes = 0,
.bitmap_format = 0,
#if LVGL_VERSION_MAJOR == 8
.cache = &cache
#endif
};
/*-----------------
* PUBLIC FONT
*----------------*/
/*Initialize a public general font descriptor*/
#if LVGL_VERSION_MAJOR >= 8
const lv_font_t material_symbols_shared_20 = {
#else
lv_font_t material_symbols_shared_20 = {
#endif
.get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt, /*Function pointer to get glyph's data*/
.get_glyph_bitmap = lv_font_get_bitmap_fmt_txt, /*Function pointer to get glyph's bitmap*/
.line_height = 18, /*The maximum line height required by the font*/
.base_line = -1, /*Baseline measured from the bottom of the line*/
#if !(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0)
.subpx = LV_FONT_SUBPX_NONE,
#endif
#if LV_VERSION_CHECK(7, 4, 0) || LVGL_VERSION_MAJOR >= 8
.underline_position = 0,
.underline_thickness = 0,
#endif
.dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */
#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9
.fallback = NULL,
#endif
.user_data = NULL,
};
#endif /*#if MATERIAL_SYMBOLS_SHARED_20*/
@@ -0,0 +1,155 @@
/*******************************************************************************
* Size: 32 px
* Bpp: 2
* Opts: --no-compress --no-prefilter --bpp 2 --size 32 --font MaterialSymbolsRounded.ttf -r 0xF53E,0xF1C6 --format lvgl -o ..\source-fonts\material_symbols_shared_32.c --force-fast-kern-format
******************************************************************************/
#include "lvgl.h"
#ifndef MATERIAL_SYMBOLS_SHARED_32
#define MATERIAL_SYMBOLS_SHARED_32 1
#endif
#if MATERIAL_SYMBOLS_SHARED_32
/*-----------------
* BITMAPS
*----------------*/
/*Store the image of the glyphs*/
static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {
/* U+F1C6 "" */
0x2f, 0xff, 0xff, 0xff, 0x40, 0x0, 0xbf, 0xff,
0xff, 0xff, 0xe0, 0x0, 0xfe, 0xaa, 0xaa, 0xab,
0xf8, 0x0, 0xf8, 0x0, 0x0, 0x1, 0xfe, 0x0,
0xf8, 0x0, 0x0, 0x0, 0x7f, 0x80, 0xf8, 0xa,
0xaa, 0xa0, 0x1f, 0xe0, 0xf8, 0x2f, 0xff, 0xf8,
0x7, 0xf8, 0xf8, 0x1f, 0xff, 0xf4, 0x1, 0xfd,
0xf8, 0x0, 0x0, 0x0, 0x0, 0x7f, 0xf8, 0x0,
0x0, 0x0, 0x0, 0x2f, 0xf8, 0x5, 0x55, 0x55,
0x50, 0x2f, 0xf8, 0x2f, 0xff, 0xff, 0xf8, 0x2f,
0xf8, 0x2f, 0xff, 0xff, 0xf8, 0x2f, 0xf8, 0x5,
0x55, 0x55, 0x50, 0x2f, 0xf8, 0x0, 0x0, 0x0,
0x0, 0x2f, 0xf8, 0x0, 0x0, 0x0, 0x0, 0x2f,
0xf8, 0x1f, 0xff, 0xff, 0xf4, 0x2f, 0xf8, 0x2f,
0xff, 0xff, 0xf8, 0x2f, 0xf8, 0xa, 0xaa, 0xaa,
0xa0, 0x2f, 0xf8, 0x0, 0x0, 0x0, 0x0, 0x2f,
0xf8, 0x0, 0x0, 0x0, 0x0, 0x2f, 0xfe, 0xaa,
0xaa, 0xaa, 0xaa, 0xbf, 0xbf, 0xff, 0xff, 0xff,
0xff, 0xfe, 0x2f, 0xff, 0xff, 0xff, 0xff, 0xf8,
/* U+F53E "" */
0x1, 0x6a, 0xaa, 0xaa, 0xa9, 0x0, 0xff, 0xff,
0xff, 0xff, 0xfc, 0x3f, 0xff, 0xff, 0xff, 0xff,
0xe7, 0xf4, 0xf8, 0x0, 0x0, 0x3e, 0xbc, 0xf,
0x80, 0x0, 0x3, 0xeb, 0xc0, 0xf8, 0x0, 0x0,
0x3e, 0xbc, 0xf, 0x80, 0x0, 0x3, 0xeb, 0xc0,
0xf8, 0x0, 0x0, 0x3e, 0xbc, 0xf, 0x80, 0x0,
0x3, 0xeb, 0xc0, 0xf8, 0x0, 0x0, 0x3e, 0xbc,
0xf, 0x80, 0x0, 0x3, 0xeb, 0xc0, 0xf8, 0x0,
0x0, 0x3e, 0xbc, 0xf, 0x80, 0x0, 0x3, 0xeb,
0xc0, 0xf8, 0x0, 0x0, 0x3e, 0xbc, 0xf, 0x80,
0x0, 0x3, 0xeb, 0xc0, 0xf8, 0x0, 0x0, 0x3e,
0xbc, 0xf, 0x80, 0x0, 0x3, 0xeb, 0xc0, 0xf8,
0x0, 0x0, 0x3e, 0xbe, 0xff, 0xff, 0xff, 0xff,
0xeb, 0xff, 0xff, 0xff, 0xff, 0xfe, 0xbf, 0xea,
0xaa, 0xaa, 0xbf, 0xdb, 0xd0, 0x0, 0x0, 0x3,
0xf0, 0xbc, 0x0, 0x0, 0x0, 0x3e, 0xb, 0xd0,
0x0, 0x0, 0x3, 0xf0, 0x3f, 0xaa, 0xaa, 0xaa,
0xbf, 0xc2, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x7,
0xff, 0xff, 0xff, 0xff, 0xd0, 0x0, 0x0, 0x0,
0x0, 0x0
};
/*---------------------
* GLYPH DESCRIPTION
*--------------------*/
static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {
{.bitmap_index = 0, .adv_w = 0, .box_w = 0, .box_h = 0, .ofs_x = 0, .ofs_y = 0} /* id = 0 reserved */,
{.bitmap_index = 0, .adv_w = 512, .box_w = 24, .box_h = 24, .ofs_x = 4, .ofs_y = 4},
{.bitmap_index = 144, .adv_w = 512, .box_w = 22, .box_h = 28, .ofs_x = 5, .ofs_y = 2}
};
/*---------------------
* CHARACTER MAPPING
*--------------------*/
static const uint16_t unicode_list_0[] = {
0x0, 0x378
};
/*Collect the unicode lists and glyph_id offsets*/
static const lv_font_fmt_txt_cmap_t cmaps[] =
{
{
.range_start = 61894, .range_length = 889, .glyph_id_start = 1,
.unicode_list = unicode_list_0, .glyph_id_ofs_list = NULL, .list_length = 2, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY
}
};
/*--------------------
* ALL CUSTOM DATA
*--------------------*/
#if LVGL_VERSION_MAJOR == 8
/*Store all the custom data of the font*/
static lv_font_fmt_txt_glyph_cache_t cache;
#endif
#if LVGL_VERSION_MAJOR >= 8
static const lv_font_fmt_txt_dsc_t font_dsc = {
#else
static lv_font_fmt_txt_dsc_t font_dsc = {
#endif
.glyph_bitmap = glyph_bitmap,
.glyph_dsc = glyph_dsc,
.cmaps = cmaps,
.kern_dsc = NULL,
.kern_scale = 0,
.cmap_num = 1,
.bpp = 2,
.kern_classes = 0,
.bitmap_format = 0,
#if LVGL_VERSION_MAJOR == 8
.cache = &cache
#endif
};
/*-----------------
* PUBLIC FONT
*----------------*/
/*Initialize a public general font descriptor*/
#if LVGL_VERSION_MAJOR >= 8
const lv_font_t material_symbols_shared_32 = {
#else
lv_font_t material_symbols_shared_32 = {
#endif
.get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt, /*Function pointer to get glyph's data*/
.get_glyph_bitmap = lv_font_get_bitmap_fmt_txt, /*Function pointer to get glyph's bitmap*/
.line_height = 28, /*The maximum line height required by the font*/
.base_line = -2, /*Baseline measured from the bottom of the line*/
#if !(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0)
.subpx = LV_FONT_SUBPX_NONE,
#endif
#if LV_VERSION_CHECK(7, 4, 0) || LVGL_VERSION_MAJOR >= 8
.underline_position = 0,
.underline_thickness = 0,
#endif
.dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */
#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9
.fallback = NULL,
#endif
.user_data = NULL,
};
#endif /*#if MATERIAL_SYMBOLS_SHARED_32*/
+11
View File
@@ -0,0 +1,11 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.epubreader
versionName=0.1.0
versionCode=1
name=Epub Reader
description=Epub and text file reader. Requires PSRAM!
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(M5UnitTest)
tactility_project(M5UnitTest)
+10
View File
@@ -0,0 +1,10 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c*)
file(GLOB_RECURSE UNIT_MODULE_FILES ../../../Libraries/M5UnitModules/Source/*.c*)
idf_component_register(
SRCS ${SOURCE_FILES} ${UNIT_MODULE_FILES}
# Library headers must be included directly,
# because all regular dependencies get stripped by elf_loader's cmake script
INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include ../../../Libraries/M5UnitModules/Include
REQUIRES TactilitySDK
)
+121
View File
@@ -0,0 +1,121 @@
#include "M5UnitTest.h"
#include "TestListView.h"
#include "TestViewBase.h"
#include "TestUnit8Encoder.h"
#include "TestUnitByteButton.h"
#include "TestUnitJoystick2.h"
#include "TestUnitScroll.h"
#include "TestUnitPaHub.h"
#include "TestUnitLcd.h"
#include "TestUnitDualButton.h"
#include "TestUnitCardKB2.h"
#include "TestUnitMidi.h"
#include "TestUnitRfid2.h"
#include "TestUnitLcdGfx.h"
#include <tactility/device.h>
#include <tt_lvgl_toolbar.h>
#include <esp_log.h>
constexpr auto* TAG = "M5UnitTest";
M5UnitTest* M5UnitTest::s_instance = nullptr;
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
void M5UnitTest::onShow(AppHandle handle, lv_obj_t* parent) {
s_instance = this;
appHandle_ = handle;
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
createWrapper(parent);
if (!listView_) {
listView_ = new TestListView();
listView_->onStart(wrapper_, handle, this);
}
}
void M5UnitTest::onHide(AppHandle handle) {
if (activeTestView_) {
activeTestView_->onStop();
delete activeTestView_;
activeTestView_ = nullptr;
}
if (listView_) listView_->onStop();
delete listView_;
listView_ = nullptr;
wrapper_ = nullptr;
appHandle_ = nullptr;
s_instance = nullptr;
}
// ---------------------------------------------------------------------------
// View switching
// ---------------------------------------------------------------------------
template<typename T>
static TestViewBase* makeTestView(lv_obj_t* wrapper, AppHandle handle, M5UnitTest* app) {
auto* v = new T();
v->onStart(wrapper, handle, app);
return v;
}
void M5UnitTest::createWrapper(lv_obj_t* parent) {
wrapper_ = lv_obj_create(parent);
lv_obj_set_width(wrapper_, LV_PCT(100));
lv_obj_set_flex_grow(wrapper_, 1);
lv_obj_set_layout(wrapper_, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(wrapper_, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(wrapper_, 0, 0);
lv_obj_set_style_bg_opa(wrapper_, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(wrapper_, 0, 0);
}
void M5UnitTest::showTest(int unitIndex) {
// Tear down any active test view and the list view, then clean the wrapper
if (activeTestView_) {
activeTestView_->onStop();
delete activeTestView_;
activeTestView_ = nullptr;
}
if (listView_) {
listView_->onStop();
delete listView_;
listView_ = nullptr;
}
lv_obj_clean(wrapper_);
ESP_LOGI(TAG, "Opening test for unit %d", unitIndex);
switch (unitIndex) {
case 0: activeTestView_ = makeTestView<TestUnit8Encoder> (wrapper_, appHandle_, this); break;
case 1: activeTestView_ = makeTestView<TestUnitByteButton>(wrapper_, appHandle_, this); break;
case 2: activeTestView_ = makeTestView<TestUnitJoystick2> (wrapper_, appHandle_, this); break;
case 3: activeTestView_ = makeTestView<TestUnitScroll> (wrapper_, appHandle_, this); break;
case 4: activeTestView_ = makeTestView<TestUnitPaHub> (wrapper_, appHandle_, this); break;
case 5: activeTestView_ = makeTestView<TestUnitLcd> (wrapper_, appHandle_, this); break;
case 6: activeTestView_ = makeTestView<TestUnitLcdGfx> (wrapper_, appHandle_, this); break;
case 7: activeTestView_ = makeTestView<TestUnitDualButton>(wrapper_, appHandle_, this); break;
case 8: activeTestView_ = makeTestView<TestUnitCardKB2> (wrapper_, appHandle_, this); break;
case 9: activeTestView_ = makeTestView<TestUnitMidi> (wrapper_, appHandle_, this); break;
case 10: activeTestView_ = makeTestView<TestUnitRfid2> (wrapper_, appHandle_, this); break;
default: showList(); return;
}
}
void M5UnitTest::showList() {
if (activeTestView_) {
activeTestView_->onStop();
delete activeTestView_;
activeTestView_ = nullptr;
}
lv_obj_clean(wrapper_);
listView_ = new TestListView();
listView_->onStart(wrapper_, appHandle_, this);
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <tt_app.h>
#include <lvgl.h>
#include <TactilityCpp/App.h>
// Forward declarations for test views
class TestListView;
class TestViewBase;
class M5UnitTest final : public App {
public:
void onShow(AppHandle handle, lv_obj_t* parent) override;
void onHide(AppHandle handle) override;
// Called by TestListView when user selects a unit to test
void showTest(int unitIndex);
// Called by test views when user presses back
void showList();
// Called by the Back button path after the view has already been deleted
void clearActiveTestView() { activeTestView_ = nullptr; }
AppHandle getAppHandle() const { return appHandle_; }
private:
AppHandle appHandle_ = nullptr;
lv_obj_t* wrapper_ = nullptr; // full-screen container, cleaned between views
TestListView* listView_ = nullptr;
TestViewBase* activeTestView_ = nullptr;
static M5UnitTest* s_instance;
void createWrapper(lv_obj_t* parent);
};
@@ -0,0 +1,44 @@
#include "TestListView.h"
#include "M5UnitTest.h"
#include "UiScale.h"
#include <tt_lvgl_toolbar.h>
#include <tactility/lvgl_fonts.h>
void TestListView::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
tt_lvgl_toolbar_create_for_app(parent, handle);
list_ = lv_list_create(parent);
lv_obj_set_width(list_, LV_PCT(100));
lv_obj_set_flex_grow(list_, 1);
lv_obj_set_style_pad_all(list_, uiPad(), 0);
lv_obj_set_style_pad_row(list_, uiRowGap(), 0);
lv_obj_set_style_border_width(list_, 0, 0);
lv_obj_set_style_bg_opa(list_, LV_OPA_TRANSP, 0);
const lv_font_t* font = lvgl_get_text_font(uiFont());
for (int i = 0; i < UNIT_COUNT; i++) {
lv_obj_t* btn = lv_list_add_button(list_, UNIT_ICONS[i], UNIT_NAMES[i]);
lv_obj_set_user_data(btn, (void*)(intptr_t)i);
lv_obj_add_event_cb(btn, onBtnClicked, LV_EVENT_CLICKED, this);
// lv_list_add_button creates: child 0 = icon label, child 1 = text label
lv_obj_t* textLbl = lv_obj_get_child(btn, 1);
if (textLbl) lv_obj_set_style_text_font(textLbl, font, 0);
lv_obj_t* iconLbl = lv_obj_get_child(btn, 0);
if (iconLbl) lv_obj_set_style_text_font(iconLbl, lvgl_get_shared_icon_font(), 0);
}
}
void TestListView::onStop() {
list_ = nullptr;
app_ = nullptr;
}
void TestListView::onBtnClicked(lv_event_t* e) {
auto* self = static_cast<TestListView*>(lv_event_get_user_data(e));
lv_obj_t* btn = static_cast<lv_obj_t*>(lv_event_get_target(e));
int idx = (int)(intptr_t)lv_obj_get_user_data(btn);
if (self && self->app_) self->app_->showTest(idx);
}
@@ -0,0 +1,49 @@
#pragma once
#include <array>
#include <lvgl.h>
#include <tactility/lvgl_icon_shared.h>
#include <tt_app.h>
class M5UnitTest;
class TestListView {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app);
void onStop();
private:
lv_obj_t* list_ = nullptr;
M5UnitTest* app_ = nullptr;
static constexpr std::array<const char*, 11> UNIT_NAMES = {{
"8Encoder",
"ByteButton",
"Joystick2",
"Scroll",
"PaHub",
"Color LCD",
"LCD Gfx Test",
"Dual-Button",
"CardKB2",
"MIDI / Synth",
"RFID 2",
}};
// Interface icons from shared Material icon font
static constexpr std::array<const char*, 11> UNIT_ICONS = {{
LVGL_ICON_SHARED_SETTINGS, // 8Encoder - I2C
LVGL_ICON_SHARED_SETTINGS, // ByteButton - I2C
LVGL_ICON_SHARED_GAMEPAD, // Joystick2 - I2C
LVGL_ICON_SHARED_SETTINGS, // Scroll - I2C
LVGL_ICON_SHARED_HUB, // PaHub - I2C
LVGL_ICON_SHARED_DEVICES, // Color LCD - I2C
LVGL_ICON_SHARED_AREA_CHART, // LCD Gfx Test - I2C
LVGL_ICON_SHARED_ELECTRIC_BOLT, // Dual-Button - GPIO
LVGL_ICON_SHARED_KEYBOARD_ALT, // CardKB2 - I2C
LVGL_ICON_SHARED_MUSIC_NOTE, // MIDI / Synth - UART
LVGL_ICON_SHARED_WIFI, // RFID 2 - I2C
}};
static constexpr int UNIT_COUNT = UNIT_NAMES.size();
static void onBtnClicked(lv_event_t* e);
};
@@ -0,0 +1,175 @@
#include "TestUnit8Encoder.h"
#include "UiScale.h"
#include <tactility/device.h>
#include <tactility/lvgl_fonts.h>
#include <cstring>
void TestUnit8Encoder::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
memset(counters_, 0, sizeof(counters_));
memset(ledColors_, 0, sizeof(ledColors_));
createToolbar(parent, handle, "8Encoder");
createBanner(parent, "8Encoder", "I2C", COLOR_I2C);
int numCols = uiW() >= 800 ? 4 : (uiW() >= 200 ? 2 : 1);
int dotSz = (int)(uiShortSide() / 60);
if (dotSz < 8) dotSz = 8;
if (dotSz > 20) dotSz = 20;
const lv_font_t* fnt = lvgl_get_text_font(uiFont());
// Status label shown when not connected — sits above the grid at full width
lblStatus_ = lv_label_create(parent);
lv_obj_set_style_text_font(lblStatus_, fnt, 0);
lv_obj_set_width(lblStatus_, LV_PCT(100));
lv_obj_set_style_pad_hor(lblStatus_, uiPad(), 0);
lv_label_set_text(lblStatus_, "");
lv_obj_t* grid = lv_obj_create(parent);
lv_obj_set_width(grid, LV_PCT(100));
lv_obj_set_flex_grow(grid, 1);
lv_obj_set_layout(grid, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_set_style_pad_all(grid, uiPad(), 0);
lv_obj_set_style_pad_row(grid, uiRowGap(), 0);
lv_obj_set_style_pad_column(grid, uiPad(), 0);
lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(grid, 0, 0);
auto makeCard = [&](lv_obj_t* parent) -> lv_obj_t* {
lv_obj_t* card = lv_obj_create(parent);
lv_obj_set_width(card, numCols == 4 ? LV_PCT(23) : (numCols == 2 ? LV_PCT(48) : LV_PCT(100)));
lv_obj_set_height(card, LV_SIZE_CONTENT);
lv_obj_set_layout(card, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(card, uiPad() / 2, 0);
lv_obj_set_style_pad_column(card, uiRowGap(), 0);
lv_obj_set_style_bg_opa(card, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(card, 0, 0);
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
return card;
};
auto makeDot = [&](lv_obj_t* parent) -> lv_obj_t* {
lv_obj_t* dot = lv_obj_create(parent);
lv_obj_set_size(dot, dotSz, dotSz);
lv_obj_set_style_radius(dot, dotSz / 2, 0);
lv_obj_set_style_bg_color(dot, lv_color_hex(0x333333), 0);
lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(dot, 0, 0);
lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE);
return dot;
};
for (int i = 0; i < 8; i++) {
lv_obj_t* card = makeCard(grid);
lv_obj_t* num = lv_label_create(card);
lv_label_set_text_fmt(num, "E%d:", i + 1);
lv_obj_set_style_text_font(num, fnt, 0);
lv_obj_set_width(num, LV_SIZE_CONTENT);
lblCounters_[i] = lv_label_create(card);
lv_label_set_text(lblCounters_[i], "0");
lv_obj_set_style_text_font(lblCounters_[i], fnt, 0);
lv_obj_set_flex_grow(lblCounters_[i], 1);
dotButtons_[i] = makeDot(card);
}
// Switch row
lv_obj_t* swCard = makeCard(grid);
lv_obj_set_width(swCard, LV_PCT(100));
lv_obj_t* swLbl = lv_label_create(swCard);
lv_label_set_text(swLbl, "SW:");
lv_obj_set_style_text_font(swLbl, fnt, 0);
lv_obj_set_width(swLbl, LV_SIZE_CONTENT);
lblSwitch_ = lv_label_create(swCard);
lv_label_set_text(lblSwitch_, "off");
lv_obj_set_style_text_font(lblSwitch_, fnt, 0);
lv_obj_set_flex_grow(lblSwitch_, 1);
dotSwitch_ = makeDot(swCard);
Device* i2c = device_find_by_name("i2c1");
if (!i2c) {
lv_label_set_text(lblStatus_, "i2c1 not found");
return;
}
if (enc_.begin(i2c)) {
usingPaHub_ = false;
} else if (hub_.begin(i2c)) {
usingPaHub_ = true;
bool found = false;
for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) {
hub_.select(ch);
if (enc_.begin(i2c)) found = true;
}
if (!found) {
hub_.deselect();
lv_label_set_text(lblStatus_, "8Encoder not found");
return;
}
} else {
lv_label_set_text(lblStatus_, "8Encoder not found");
return;
}
timer_ = lv_timer_create(onTimer, 50, this);
update();
}
void TestUnit8Encoder::onStop() {
if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; }
selectIfNeeded();
if (enc_.isPresent()) enc_.setAllLeds(0x000000);
if (usingPaHub_ && hub_.isPresent()) hub_.deselect();
lblStatus_ = nullptr;
memset(lblCounters_, 0, sizeof(lblCounters_));
memset(dotButtons_, 0, sizeof(dotButtons_));
lblSwitch_ = dotSwitch_ = nullptr;
}
void TestUnit8Encoder::onTimer(lv_timer_t* t) {
static_cast<TestUnit8Encoder*>(lv_timer_get_user_data(t))->update();
}
void TestUnit8Encoder::selectIfNeeded() {
if (usingPaHub_ && hub_.isPresent())
hub_.select(hub_.currentChannel());
}
void TestUnit8Encoder::update() {
selectIfNeeded();
if (!enc_.isPresent()) return;
int32_t deltas[8];
uint8_t buttons[8];
if (!enc_.readAll(deltas, buttons)) return;
for (int i = 0; i < 8; i++) {
counters_[i] += deltas[i];
lv_label_set_text_fmt(lblCounters_[i], "%ld", (long)counters_[i]);
lv_color_t dotCol = buttons[i] ? lv_color_hex(0x00DD44) : lv_color_hex(0x333333);
lv_obj_set_style_bg_color(dotButtons_[i], dotCol, 0);
// Encoder LED: hue cycles with counter position
uint32_t hue = (uint32_t)((counters_[i] % 360 + 360) % 360);
lv_color_t c = lv_color_hsv_to_rgb((uint16_t)hue, 100, 78);
lv_color32_t c32 = lv_color_to_32(c, LV_OPA_COVER);
ledColors_[i] = ((uint32_t)c32.red << 16) | ((uint32_t)c32.green << 8) | c32.blue;
}
// Switch (index 8): green=on, dark gray=off; skip update on I2C error
bool sw = false;
if (enc_.readSwitch(sw)) {
lv_label_set_text(lblSwitch_, sw ? "on" : "off");
lv_obj_set_style_bg_color(dotSwitch_, lv_color_hex(sw ? 0x00DD44 : 0x333333), 0);
ledColors_[8] = sw ? 0x00DD44 : 0x000000;
}
enc_.flushLeds(ledColors_);
}
@@ -0,0 +1,27 @@
#pragma once
#include "TestViewBase.h"
#include <UnitPaHub.h>
#include <Unit8Encoder.h>
class TestUnit8Encoder final : public TestViewBase {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override;
void onStop() override;
private:
UnitPaHub hub_;
Unit8Encoder enc_;
lv_obj_t* lblStatus_ = nullptr;
lv_obj_t* lblCounters_[8] = {};
lv_obj_t* dotButtons_[8] = {};
lv_obj_t* lblSwitch_ = nullptr;
lv_obj_t* dotSwitch_ = nullptr;
lv_timer_t* timer_ = nullptr;
int32_t counters_[8] = {};
uint32_t ledColors_[Unit8Encoder::LED_COUNT] = {};
bool usingPaHub_ = false;
static void onTimer(lv_timer_t* t);
void update();
void selectIfNeeded();
};
@@ -0,0 +1,125 @@
#include "TestUnitByteButton.h"
#include "UiScale.h"
#include <tactility/device.h>
#include <tactility/lvgl_fonts.h>
#include <cstring>
void TestUnitByteButton::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
memset(ledColors_, 0, sizeof(ledColors_));
memset(prevPressed_, 0, sizeof(prevPressed_));
createToolbar(parent, handle, "ByteButton");
createBanner(parent, "ByteButton", "I2C", COLOR_I2C);
int dotSz = (int)(uiShortSide() / 14);
if (dotSz < 20) dotSz = 20;
if (dotSz > 64) dotSz = 64;
lv_obj_t* cont = lv_obj_create(parent);
lv_obj_set_width(cont, LV_PCT(100));
lv_obj_set_flex_grow(cont, 1);
lv_obj_set_layout(cont, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(cont, uiRowGap() * 2, 0);
lv_obj_set_style_pad_all(cont, uiPad(), 0);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
// Dot grid - flex wrap row so it auto-adjusts columns
lv_obj_t* dotGrid = lv_obj_create(cont);
lv_obj_set_width(dotGrid, LV_PCT(100));
lv_obj_set_height(dotGrid, LV_SIZE_CONTENT);
lv_obj_set_layout(dotGrid, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(dotGrid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(dotGrid, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(dotGrid, uiRowGap(), 0);
lv_obj_set_style_pad_column(dotGrid, uiRowGap(), 0);
lv_obj_set_style_bg_opa(dotGrid, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(dotGrid, 0, 0);
lv_obj_set_style_pad_all(dotGrid, 0, 0);
for (int i = 0; i < BTN_COUNT; i++) {
lv_obj_t* sq = lv_obj_create(dotGrid);
lv_obj_set_size(sq, dotSz, dotSz);
lv_obj_set_style_radius(sq, 4, 0);
lv_obj_set_style_bg_color(sq, lv_color_hex(COLOR_OFF), 0);
lv_obj_set_style_bg_opa(sq, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(sq, lv_color_hex(0x444444), 0);
lv_obj_set_style_border_width(sq, 1, 0);
lv_obj_remove_flag(sq, LV_OBJ_FLAG_SCROLLABLE);
indicators_[i] = sq;
}
lv_obj_t* hint = lv_label_create(cont);
lv_obj_set_style_text_font(hint, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_label_set_text(hint, "Press buttons - LEDs toggle");
Device* i2c = device_find_by_name("i2c1");
if (!i2c) {
for (int i = 0; i < BTN_COUNT; i++) lv_obj_set_style_bg_color(indicators_[i], lv_color_hex(COLOR_ERROR), 0);
lv_label_set_text(hint, "i2c1 not found");
return;
}
if (unit_.begin(i2c)) {
usingPaHub_ = false;
} else if (hub_.begin(i2c)) {
usingPaHub_ = true;
bool found = false;
for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) {
hub_.select(ch);
if (unit_.begin(i2c)) found = true;
}
if (!found) {
hub_.deselect();
for (int i = 0; i < BTN_COUNT; i++) lv_obj_set_style_bg_color(indicators_[i], lv_color_hex(COLOR_ERROR), 0);
lv_label_set_text(hint, "ByteButton not found");
return;
}
} else {
for (int i = 0; i < BTN_COUNT; i++) lv_obj_set_style_bg_color(indicators_[i], lv_color_hex(COLOR_ERROR), 0);
lv_label_set_text(hint, "ByteButton not found");
return;
}
timer_ = lv_timer_create(onTimer, 50, this);
update();
}
void TestUnitByteButton::onStop() {
if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; }
selectIfNeeded();
if (unit_.isPresent()) {
for (int i = 0; i < BTN_COUNT; i++) unit_.setLed((uint8_t)i, 0x000000);
}
if (usingPaHub_ && hub_.isPresent()) hub_.deselect();
memset(indicators_, 0, sizeof(indicators_));
}
void TestUnitByteButton::selectIfNeeded() {
if (usingPaHub_ && hub_.isPresent())
hub_.select(hub_.currentChannel());
}
void TestUnitByteButton::onTimer(lv_timer_t* t) {
static_cast<TestUnitByteButton*>(lv_timer_get_user_data(t))->update();
}
void TestUnitByteButton::update() {
selectIfNeeded();
if (!unit_.isPresent()) return;
uint8_t mask = unit_.readButtons();
for (int i = 0; i < BTN_COUNT; i++) {
bool pressed = (mask >> i) & 0x01;
// Toggle LED only on rising edge (press, not hold)
if (pressed && !prevPressed_[i]) {
ledColors_[i] = (ledColors_[i] == 0) ? COLOR_ON : 0;
unit_.setLed((uint8_t)i, ledColors_[i]);
}
prevPressed_[i] = pressed;
lv_color_t col = pressed ? lv_color_hex(COLOR_PRESSED) : lv_color_hex(COLOR_OFF);
lv_obj_set_style_bg_color(indicators_[i], col, 0);
}
}
@@ -0,0 +1,29 @@
#pragma once
#include "TestViewBase.h"
#include <UnitByteButton.h>
#include <UnitPaHub.h>
class TestUnitByteButton final : public TestViewBase {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override;
void onStop() override;
private:
static constexpr int BTN_COUNT = UnitByteButton::BUTTON_COUNT;
static constexpr uint32_t COLOR_OFF = 0x001100;
static constexpr uint32_t COLOR_ON = 0x00FF44;
static constexpr uint32_t COLOR_ERROR = 0x440000;
static constexpr uint32_t COLOR_PRESSED = 0xFFFF00;
UnitPaHub hub_;
UnitByteButton unit_;
lv_obj_t* indicators_[BTN_COUNT] = {};
lv_timer_t* timer_ = nullptr;
uint32_t ledColors_[BTN_COUNT] = {};
bool prevPressed_[BTN_COUNT]= {};
bool usingPaHub_ = false;
void selectIfNeeded();
static void onTimer(lv_timer_t* t);
void update();
};
@@ -0,0 +1,283 @@
#include "TestUnitCardKB2.h"
#include "UiScale.h"
#include <tactility/device.h>
#include <tactility/drivers/uart_controller.h>
#include <tactility/lvgl_fonts.h>
#include <cstring>
// ---------------------------------------------------------------------------
// Physical key layout - 5 rows (4 real + arrow row).
// matchChar=0 = modifier key (no highlight target).
// Arrow keys produced by Fn+Z/X/D/C in I2C mode; by key-id in UART mode.
// ---------------------------------------------------------------------------
static const struct { const char* label; uint8_t matchChar; int grow; } LAYOUT[][12] = {
// Row 0: 1 2 3 4 5 6 7 8 9 0 Del (+ Esc shown but Esc = Fn+1, no direct key-id)
{ {"1",'1',1},{"2",'2',1},{"3",'3',1},{"4",'4',1},{"5",'5',1},{"6",'6',1},
{"7",'7',1},{"8",'8',1},{"9",'9',1},{"0",'0',1},{"Del",0x08,1},{nullptr,0,0} },
// Row 1: q w e r t y u i o p Del
{ {"q",'q',1},{"w",'w',1},{"e",'e',1},{"r",'r',1},{"t",'t',1},{"y",'y',1},
{"u",'u',1},{"i",'i',1},{"o",'o',1},{"p",'p',1},{"Del",0x08,1},{nullptr,0,0} },
// Row 2: Aa a s d f g h j k l Enter
{ {"Aa",0,1},{"a",'a',1},{"s",'s',1},{"d",'d',1},{"f",'f',1},{"g",'g',1},
{"h",'h',1},{"j",'j',1},{"k",'k',1},{"l",'l',1},{"Ent",0x0A,1},{nullptr,0,0} },
// Row 3: Fn Sym z x c v b n m Spc
{ {"Fn",0,1},{"Sym",0,1},{"z",'z',1},{"x",'x',1},{"c",'c',1},{"v",'v',1},
{"b",'b',1},{"n",'n',1},{"m",'m',1},{"Spc",0x20,3},{nullptr,0,0},{nullptr,0,0} },
// Row 4: arrow keys (ASCII from Fn combos in I2C; produced by Fn key-ids in UART)
{ {"<",0x1D,1},{"v",0x1F,1},{"^",0x1E,1},{">",0x1C,1},
{nullptr,0,0},{nullptr,0,0},{nullptr,0,0},{nullptr,0,0},{nullptr,0,0},{nullptr,0,0},{nullptr,0,0},{nullptr,0,0} },
};
static constexpr int ROW_COUNT = 5;
static constexpr int COL_COUNT = 12;
// ---------------------------------------------------------------------------
// Grid construction
// ---------------------------------------------------------------------------
void TestUnitCardKB2::buildGrid(lv_obj_t* parent) {
gridCount_ = 0;
for (int row = 0; row < ROW_COUNT; row++) {
lv_obj_t* rowCont = lv_obj_create(parent);
lv_obj_set_width(rowCont, LV_PCT(100));
lv_obj_set_height(rowCont, LV_SIZE_CONTENT);
lv_obj_set_layout(rowCont, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(rowCont, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(rowCont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(rowCont, uiPad() / 4 + 1, 0);
lv_obj_set_style_pad_all(rowCont, 0, 0);
lv_obj_set_style_bg_opa(rowCont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(rowCont, 0, 0);
for (int col = 0; col < COL_COUNT; col++) {
if (LAYOUT[row][col].label == nullptr) break;
lv_obj_t* btn = lv_button_create(rowCont);
lv_obj_set_style_pad_hor(btn, uiPad() / 2, 0);
lv_obj_set_style_pad_ver(btn, uiRowGap(), 0);
lv_obj_set_style_radius(btn, 4, 0);
lv_obj_set_flex_grow(btn, LAYOUT[row][col].grow);
lv_obj_t* lbl = lv_label_create(btn);
lv_obj_set_style_text_font(lbl, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_label_set_text(lbl, LAYOUT[row][col].label);
lv_obj_center(lbl);
if (gridCount_ < GRID_KEY_COUNT) {
grid_[gridCount_++] = { LAYOUT[row][col].label, LAYOUT[row][col].matchChar, btn, lbl };
}
}
}
}
// ---------------------------------------------------------------------------
// Connection overlay
// ---------------------------------------------------------------------------
void TestUnitCardKB2::showConnectOverlay() {
int pad = uiPad();
int rowGap = uiRowGap();
const lv_font_t* fnt = lvgl_get_text_font(uiFont());
connectOverlay_ = lv_obj_create(parentRef_);
lv_obj_set_size(connectOverlay_, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(connectOverlay_, 0, 0);
lv_obj_set_layout(connectOverlay_, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(connectOverlay_, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(connectOverlay_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(connectOverlay_, pad, 0);
lv_obj_set_style_pad_row(connectOverlay_, rowGap * 2, 0);
lv_obj_t* title = lv_label_create(connectOverlay_);
lv_obj_set_style_text_font(title, fnt, 0);
lv_label_set_text(title, "Select connection mode");
lv_obj_t* hint = lv_label_create(connectOverlay_);
lv_obj_set_style_text_font(hint, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_color(hint, lv_color_hex(0x888888), 0);
lv_label_set_text(hint, "Fn+Sym+1 = I2C | Fn+Sym+2 = UART");
auto makeBtn = [&](const char* label, lv_event_cb_t cb) {
lv_obj_t* btn = lv_button_create(connectOverlay_);
lv_obj_set_width(btn, LV_PCT(60));
lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, this);
lv_obj_t* lbl = lv_label_create(btn);
lv_obj_set_style_text_font(lbl, fnt, 0);
lv_label_set_text(lbl, label);
lv_obj_center(lbl);
};
makeBtn("I2C (Grove port)", onI2CBtn);
makeBtn("UART (Grove port)", onUartBtn);
}
// ---------------------------------------------------------------------------
// Connect handlers
// ---------------------------------------------------------------------------
void TestUnitCardKB2::connectI2C() {
lv_obj_delete(connectOverlay_);
connectOverlay_ = nullptr;
Device* i2c = device_find_by_name("i2c1");
if (!i2c) {
buildMainUI();
lv_label_set_text(lblHistory_, "i2c1 not found");
return;
}
bool ok = false;
if (unit_.begin(i2c)) {
usingPaHub_ = false;
ok = true;
} else if (hub_.begin(i2c)) {
usingPaHub_ = true;
for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !ok; ch++) {
hub_.select(ch);
if (unit_.begin(i2c)) ok = true;
}
if (!ok) hub_.deselect();
}
buildMainUI();
if (!ok) lv_label_set_text(lblHistory_, "CardKB2 not found");
else timer_ = lv_timer_create(onTimer, 50, this);
}
void TestUnitCardKB2::connectUart() {
lv_obj_delete(connectOverlay_);
connectOverlay_ = nullptr;
Device* uart = device_find_by_name("uart1");
buildMainUI();
if (!uart) {
lv_label_set_text(lblHistory_, "uart1 not found");
return;
}
if (!unit_.beginUart(uart)) {
lv_label_set_text(lblHistory_, "UART open failed");
return;
}
timer_ = lv_timer_create(onTimer, 50, this);
}
// ---------------------------------------------------------------------------
// Main content UI (built after connection type selected)
// ---------------------------------------------------------------------------
void TestUnitCardKB2::buildMainUI() {
memset(history_, 0, sizeof(history_));
histLen_ = 0;
gridCount_ = 0;
activeBtn_ = nullptr;
lv_obj_t* cont = lv_obj_create(parentRef_);
lv_obj_set_width(cont, LV_PCT(100));
lv_obj_set_flex_grow(cont, 1);
lv_obj_set_layout(cont, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_set_style_pad_row(cont, 3, 0);
lv_obj_set_style_pad_all(cont, uiPad(), 0);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
if (uiW() >= 200) buildGrid(cont);
lblHistory_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblHistory_, lvgl_get_text_font(uiFont()), 0);
lv_label_set_text(lblHistory_, "");
lv_obj_set_width(lblHistory_, LV_PCT(100));
lv_label_set_long_mode(lblHistory_, LV_LABEL_LONG_WRAP);
lv_obj_set_flex_grow(lblHistory_, 1);
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
void TestUnitCardKB2::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
parentRef_ = parent;
handleRef_ = handle;
createToolbar(parent, handle, "CardKB2");
createBanner(parent, "CardKB2", "I2C/UART", COLOR_I2C);
showConnectOverlay();
}
void TestUnitCardKB2::onStop() {
if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; }
if (usingPaHub_ && hub_.isPresent()) hub_.deselect();
unit_.end();
lblHistory_ = nullptr;
connectOverlay_ = nullptr;
activeBtn_ = nullptr;
gridCount_ = 0;
}
// ---------------------------------------------------------------------------
// Static callbacks
// ---------------------------------------------------------------------------
void TestUnitCardKB2::onI2CBtn(lv_event_t* e) {
static_cast<TestUnitCardKB2*>(lv_event_get_user_data(e))->connectI2C();
}
void TestUnitCardKB2::onUartBtn(lv_event_t* e) {
static_cast<TestUnitCardKB2*>(lv_event_get_user_data(e))->connectUart();
}
void TestUnitCardKB2::onTimer(lv_timer_t* t) {
static_cast<TestUnitCardKB2*>(lv_timer_get_user_data(t))->update();
}
// ---------------------------------------------------------------------------
// PaHub helper
// ---------------------------------------------------------------------------
void TestUnitCardKB2::selectIfNeeded() {
if (usingPaHub_ && hub_.isPresent())
hub_.select(hub_.currentChannel());
}
// ---------------------------------------------------------------------------
// Update (called from timer)
// ---------------------------------------------------------------------------
void TestUnitCardKB2::update() {
if (!unit_.isPresent()) return;
if (unit_.mode() == UnitCardKB2::Mode::I2C) selectIfNeeded();
char c = unit_.getKey();
// Grid highlight
if (gridCount_ > 0) {
lv_obj_t* newActive = nullptr;
if (c != 0) {
for (int i = 0; i < gridCount_; i++) {
if (grid_[i].matchChar == 0 || grid_[i].btn == nullptr) continue;
uint8_t mc = grid_[i].matchChar;
// Match lower and uppercase variants of letter keys
bool match = (mc == (uint8_t)c) ||
(mc >= 'a' && mc <= 'z' && mc == (uint8_t)(c | 0x20));
if (match) { newActive = grid_[i].btn; break; }
}
}
if (newActive != activeBtn_) {
if (activeBtn_) lv_obj_remove_state(activeBtn_, LV_STATE_PRESSED);
if (newActive) lv_obj_add_state(newActive, LV_STATE_PRESSED);
activeBtn_ = newActive;
}
}
// History strip - printable chars only
if (c != 0 && c >= 0x20 && c < 0x7F) {
if (histLen_ < sizeof(history_) - 1) {
history_[histLen_++] = c;
} else {
memmove(history_, history_ + 1, histLen_ - 1);
history_[histLen_ - 1] = c;
}
history_[histLen_] = '\0';
lv_label_set_text(lblHistory_, history_);
}
}
@@ -0,0 +1,51 @@
#pragma once
#include "TestViewBase.h"
#include <UnitCardKB2.h>
#include <UnitPaHub.h>
class TestUnitCardKB2 final : public TestViewBase {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override;
void onStop() override;
private:
UnitPaHub hub_;
UnitCardKB2 unit_;
lv_timer_t* timer_ = nullptr;
bool usingPaHub_ = false;
// Connection selection overlay (shown before connecting)
lv_obj_t* connectOverlay_ = nullptr;
// Main content (shown after connecting)
lv_obj_t* lblHistory_ = nullptr;
char history_[64] = {};
uint8_t histLen_ = 0;
// Keyboard grid (only built on screens >= 200px wide)
struct KeyCell {
const char* label;
uint8_t matchChar; // ASCII to highlight; 0 = not matchable
lv_obj_t* btn = nullptr;
lv_obj_t* lbl = nullptr;
};
static constexpr int GRID_KEY_COUNT = 52;
KeyCell grid_[GRID_KEY_COUNT] = {};
int gridCount_ = 0;
lv_obj_t* activeBtn_ = nullptr;
lv_obj_t* parentRef_ = nullptr;
AppHandle handleRef_ = nullptr;
void showConnectOverlay();
void connectI2C();
void connectUart();
void buildMainUI();
void buildGrid(lv_obj_t* parent);
void selectIfNeeded();
static void onTimer(lv_timer_t* t);
static void onI2CBtn(lv_event_t* e);
static void onUartBtn(lv_event_t* e);
void update();
};
@@ -0,0 +1,305 @@
#include "TestUnitDualButton.h"
#include "UiScale.h"
#include <tactility/device.h>
#include <tactility/lvgl_fonts.h>
static constexpr gpio_pin_t PIN_MIN = 0;
static constexpr gpio_pin_t PIN_MAX = 57;
static constexpr lv_color_t COLOR_A_ACTIVE = LV_COLOR_MAKE(0xE0, 0x30, 0x30);
static constexpr lv_color_t COLOR_A_DIM = LV_COLOR_MAKE(0x40, 0x10, 0x10);
static constexpr lv_color_t COLOR_B_ACTIVE = LV_COLOR_MAKE(0x30, 0x60, 0xE0);
static constexpr lv_color_t COLOR_B_DIM = LV_COLOR_MAKE(0x10, 0x20, 0x50);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Scale UI elements off the shorter display side so config looks good in both orientations.
static lv_coord_t uiShort() {
lv_coord_t w = lv_display_get_horizontal_resolution(nullptr);
lv_coord_t h = lv_display_get_vertical_resolution(nullptr);
return w < h ? w : h;
}
// Button size and value label width proportional to short side, clamped to reasonable range.
static lv_coord_t uiBtnSize() { lv_coord_t s = uiShort() / 10; return s < 36 ? 36 : (s > 80 ? 80 : s); }
static lv_coord_t uiValWidth() { lv_coord_t s = uiShort() / 8; return s < 48 ? 48 : (s > 100 ? 100 : s); }
static lv_obj_t* makePinRow(lv_obj_t* parent, const char* label,
lv_event_cb_t cbDown, lv_event_cb_t cbUp,
lv_obj_t** outLbl, void* userData) {
int pad = uiPad();
int gap = uiRowGap() * 2;
lv_coord_t btnSz = uiBtnSize();
lv_coord_t valW = uiValWidth();
// Use LARGE font on larger screens (short side >= 400), DEFAULT otherwise
enum LvglFontSize fnt = uiShort() >= 400 ? FONT_SIZE_LARGE : FONT_SIZE_DEFAULT;
lv_obj_t* row = lv_obj_create(parent);
lv_obj_set_width(row, LV_PCT(100));
lv_obj_set_height(row, LV_SIZE_CONTENT);
lv_obj_set_layout(row, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(row, gap, 0);
lv_obj_set_style_pad_ver(row, pad, 0);
lv_obj_set_style_pad_hor(row, pad, 0);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_t* lbl = lv_label_create(row);
lv_label_set_text(lbl, label);
lv_obj_set_style_text_font(lbl, lvgl_get_text_font(fnt), 0);
lv_obj_t* btnMinus = lv_button_create(row);
lv_obj_set_size(btnMinus, btnSz, btnSz);
lv_obj_add_event_cb(btnMinus, cbDown, LV_EVENT_CLICKED, userData);
lv_obj_t* lblMinus = lv_label_create(btnMinus);
lv_obj_set_style_text_font(lblMinus, lvgl_get_text_font(fnt), 0);
lv_label_set_text(lblMinus, "-");
lv_obj_center(lblMinus);
lv_obj_t* valLbl = lv_label_create(row);
lv_obj_set_style_text_font(valLbl, lvgl_get_text_font(fnt), 0);
lv_obj_set_width(valLbl, valW);
lv_obj_set_style_text_align(valLbl, LV_TEXT_ALIGN_CENTER, 0);
*outLbl = valLbl;
lv_obj_t* btnPlus = lv_button_create(row);
lv_obj_set_size(btnPlus, btnSz, btnSz);
lv_obj_add_event_cb(btnPlus, cbUp, LV_EVENT_CLICKED, userData);
lv_obj_t* lblPlus = lv_label_create(btnPlus);
lv_obj_set_style_text_font(lblPlus, lvgl_get_text_font(fnt), 0);
lv_label_set_text(lblPlus, "+");
lv_obj_center(lblPlus);
return row;
}
// ---------------------------------------------------------------------------
// onStart
// ---------------------------------------------------------------------------
void TestUnitDualButton::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
createToolbar(parent, handle, "Dual-Button");
createBanner(parent, "Dual-Button", "GPIO", COLOR_GPIO);
buildConfigScreen(parent);
}
void TestUnitDualButton::buildConfigScreen(lv_obj_t* parent) {
int pad = uiPad();
int gap = uiRowGap();
lv_obj_t* cont = lv_obj_create(parent);
lv_obj_set_width(cont, LV_PCT(100));
lv_obj_set_flex_grow(cont, 1);
lv_obj_set_layout(cont, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(cont, gap, 0);
lv_obj_set_style_pad_all(cont, pad, 0);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
makePinRow(cont, "Pin A:", onPinADown, onPinAUp, &lblPinA_, this);
lv_label_set_text_fmt(lblPinA_, "%d", (int)pinA_);
makePinRow(cont, "Pin B:", onPinBDown, onPinBUp, &lblPinB_, this);
lv_label_set_text_fmt(lblPinB_, "%d", (int)pinB_);
enum LvglFontSize fnt = uiShort() >= 400 ? FONT_SIZE_LARGE : FONT_SIZE_DEFAULT;
lv_coord_t btnH = uiBtnSize() * 3 / 2;
lv_obj_t* btnConnect = lv_button_create(cont);
lv_obj_set_width(btnConnect, LV_PCT(60));
lv_obj_set_height(btnConnect, btnH);
lv_obj_add_event_cb(btnConnect, onConnect, LV_EVENT_CLICKED, this);
lv_obj_t* lbl = lv_label_create(btnConnect);
lv_obj_set_style_text_font(lbl, lvgl_get_text_font(fnt), 0);
lv_label_set_text(lbl, "Connect");
lv_obj_center(lbl);
lblError_ = lv_label_create(cont);
lv_obj_set_style_text_color(lblError_, lv_color_make(0xE0, 0x40, 0x40), 0);
lv_obj_set_style_text_font(lblError_, lvgl_get_text_font(fnt), 0);
lv_label_set_text(lblError_, "");
}
void TestUnitDualButton::buildTestScreen(lv_obj_t* parent) {
// Remove config screen (last child added - the cont with flex_grow=1)
// We clean the parent area by deleting children after toolbar+banner (first 2),
// so instead we track via the cont pointer approach: just clean parent and rebuild
// toolbar+banner are already created; we delete everything after them by cleaning
// what was added by buildConfigScreen. The simplest approach: delete the cont we
// created. Since we don't store it, clean children from index 2 onward.
uint32_t childCnt = lv_obj_get_child_count(parent);
for (uint32_t i = childCnt; i > 2; --i) {
lv_obj_delete(lv_obj_get_child(parent, i - 1));
}
lblPinA_ = lblPinB_ = lblError_ = nullptr;
int pad = uiPad();
lv_coord_t dispW = lv_display_get_horizontal_resolution(nullptr);
lv_coord_t dispH = lv_display_get_vertical_resolution(nullptr);
bool landscape = dispW > dispH;
// Diameter: fit two circles side-by-side in landscape, stacked in portrait.
// Base off the shorter display dimension to avoid blowing up on wide screens.
lv_coord_t shortSide = dispW < dispH ? dispW : dispH;
lv_coord_t overhead = 40 + 28 + pad * 2; // toolbar + banner + padding
lv_coord_t diam;
if (landscape) {
// Side by side: diameter limited by available height and half the width
lv_coord_t byH = dispH - overhead - pad * 2;
lv_coord_t byW = (dispW - pad * 3) / 2;
diam = byH < byW ? byH : byW;
} else {
// Stacked: diameter limited by width and half available height
lv_coord_t byW = dispW - pad * 2;
lv_coord_t byH = (dispH - overhead - pad * 3) / 2;
diam = byW < byH ? byW : byH;
}
// Cap at 40% of short side so circles never dominate the whole screen
lv_coord_t cap = shortSide * 2 / 5;
if (diam > cap) diam = cap;
if (diam < 20) diam = 20;
lv_obj_t* cont = lv_obj_create(parent);
lv_obj_set_width(cont, LV_PCT(100));
lv_obj_set_flex_grow(cont, 1);
lv_obj_set_layout(cont, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(cont, landscape ? LV_FLEX_FLOW_ROW : LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(cont, pad, 0);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
// Circle A (red)
circleA_ = lv_obj_create(cont);
lv_obj_set_size(circleA_, diam, diam);
lv_obj_set_style_radius(circleA_, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(circleA_, COLOR_A_DIM, 0);
lv_obj_set_style_bg_opa(circleA_, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(circleA_, 0, 0);
lv_obj_set_style_pad_all(circleA_, 0, 0);
circleLblA_ = lv_label_create(circleA_);
lv_label_set_text(circleLblA_, "A");
lv_obj_set_style_text_font(circleLblA_, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
lv_obj_set_style_text_color(circleLblA_, lv_color_make(0x80, 0x40, 0x40), 0);
lv_obj_set_style_text_align(circleLblA_, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(circleLblA_, LV_ALIGN_CENTER, 0, 0);
// Circle B (blue)
circleB_ = lv_obj_create(cont);
lv_obj_set_size(circleB_, diam, diam);
lv_obj_set_style_radius(circleB_, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(circleB_, COLOR_B_DIM, 0);
lv_obj_set_style_bg_opa(circleB_, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(circleB_, 0, 0);
lv_obj_set_style_pad_all(circleB_, 0, 0);
circleLblB_ = lv_label_create(circleB_);
lv_label_set_text(circleLblB_, "B");
lv_obj_set_style_text_font(circleLblB_, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
lv_obj_set_style_text_color(circleLblB_, lv_color_make(0x40, 0x50, 0x80), 0);
lv_obj_set_style_text_align(circleLblB_, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(circleLblB_, LV_ALIGN_CENTER, 0, 0);
timer_ = lv_timer_create(onTimer, 50, this);
update();
}
// ---------------------------------------------------------------------------
// onStop
// ---------------------------------------------------------------------------
void TestUnitDualButton::onStop() {
if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; }
if (connected_) { unit_.end(); connected_ = false; }
lblPinA_ = lblPinB_ = lblError_ = nullptr;
circleA_ = circleB_ = nullptr;
circleLblA_ = circleLblB_ = nullptr;
pinA_ = 0;
pinB_ = 49;
}
// ---------------------------------------------------------------------------
// update (polls hardware, refreshes circles)
// ---------------------------------------------------------------------------
void TestUnitDualButton::update() {
if (!unit_.isPresent() || !circleA_) return;
bool pressA = unit_.isButtonAPressed();
lv_obj_set_style_bg_color(circleA_, pressA ? COLOR_A_ACTIVE : COLOR_A_DIM, 0);
lv_label_set_text(circleLblA_, pressA ? "PRESSED" : "A");
lv_obj_set_style_text_color(circleLblA_,
pressA ? lv_color_white() : lv_color_make(0x80, 0x40, 0x40), 0);
bool pressB = unit_.isButtonBPressed();
lv_obj_set_style_bg_color(circleB_, pressB ? COLOR_B_ACTIVE : COLOR_B_DIM, 0);
lv_label_set_text(circleLblB_, pressB ? "PRESSED" : "B");
lv_obj_set_style_text_color(circleLblB_,
pressB ? lv_color_white() : lv_color_make(0x40, 0x50, 0x80), 0);
}
// ---------------------------------------------------------------------------
// Static callbacks
// ---------------------------------------------------------------------------
void TestUnitDualButton::onTimer(lv_timer_t* t) {
static_cast<TestUnitDualButton*>(lv_timer_get_user_data(t))->update();
}
void TestUnitDualButton::onPinADown(lv_event_t* e) {
auto* self = static_cast<TestUnitDualButton*>(lv_event_get_user_data(e));
if (self->pinA_ > PIN_MIN) {
self->pinA_--;
lv_label_set_text_fmt(self->lblPinA_, "%d", (int)self->pinA_);
}
}
void TestUnitDualButton::onPinAUp(lv_event_t* e) {
auto* self = static_cast<TestUnitDualButton*>(lv_event_get_user_data(e));
if (self->pinA_ < PIN_MAX) {
self->pinA_++;
lv_label_set_text_fmt(self->lblPinA_, "%d", (int)self->pinA_);
}
}
void TestUnitDualButton::onPinBDown(lv_event_t* e) {
auto* self = static_cast<TestUnitDualButton*>(lv_event_get_user_data(e));
if (self->pinB_ > PIN_MIN) {
self->pinB_--;
lv_label_set_text_fmt(self->lblPinB_, "%d", (int)self->pinB_);
}
}
void TestUnitDualButton::onPinBUp(lv_event_t* e) {
auto* self = static_cast<TestUnitDualButton*>(lv_event_get_user_data(e));
if (self->pinB_ < PIN_MAX) {
self->pinB_++;
lv_label_set_text_fmt(self->lblPinB_, "%d", (int)self->pinB_);
}
}
void TestUnitDualButton::onConnect(lv_event_t* e) {
auto* self = static_cast<TestUnitDualButton*>(lv_event_get_user_data(e));
Device* gpio = device_find_by_name("gpio0");
if (!gpio || !self->unit_.begin(gpio, self->pinA_, self->pinB_)) {
if (self->lblError_) {
lv_label_set_text(self->lblError_, "GPIO init failed - check pins");
}
return;
}
self->connected_ = true;
// Obtain the parent (grandparent of the button's container)
lv_obj_t* btn = lv_event_get_target_obj(e);
lv_obj_t* cont = lv_obj_get_parent(btn);
lv_obj_t* parent = lv_obj_get_parent(cont);
self->buildTestScreen(parent);
}
@@ -0,0 +1,40 @@
#pragma once
#include "TestViewBase.h"
#include <UnitDualButton.h>
#include <tactility/drivers/gpio_controller.h>
class TestUnitDualButton final : public TestViewBase {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override;
void onStop() override;
private:
UnitDualButton unit_;
bool connected_ = false;
// Config screen
gpio_pin_t pinA_ = 0;
gpio_pin_t pinB_ = 49;
lv_obj_t* lblPinA_ = nullptr;
lv_obj_t* lblPinB_ = nullptr;
lv_obj_t* lblError_ = nullptr;
// Test screen
lv_obj_t* circleA_ = nullptr;
lv_obj_t* circleB_ = nullptr;
lv_obj_t* circleLblA_ = nullptr;
lv_obj_t* circleLblB_ = nullptr;
lv_timer_t* timer_ = nullptr;
void buildConfigScreen(lv_obj_t* parent);
void buildTestScreen(lv_obj_t* parent);
void update();
static void onTimer(lv_timer_t* t);
static void onPinADown(lv_event_t* e);
static void onPinAUp(lv_event_t* e);
static void onPinBDown(lv_event_t* e);
static void onPinBUp(lv_event_t* e);
static void onConnect(lv_event_t* e);
};
@@ -0,0 +1,143 @@
#include "TestUnitJoystick2.h"
#include "UiScale.h"
#include <tactility/device.h>
#include <tactility/lvgl_fonts.h>
#include <algorithm>
#include <cmath>
void TestUnitJoystick2::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
createToolbar(parent, handle, "Joystick2");
createBanner(parent, "Joystick2", "I2C", COLOR_I2C);
// Scale joystick area to shorter display dimension, clamped 80..300px
lv_coord_t minDim = std::min(uiW(), uiH());
int JOY_AREA = (int)(minDim * 3 / 10);
if (JOY_AREA < 80) JOY_AREA = 80;
if (JOY_AREA > 300) JOY_AREA = 300;
int DOT_SIZE = JOY_AREA / 8;
if (DOT_SIZE < 8) DOT_SIZE = 8;
lv_obj_t* cont = lv_obj_create(parent);
lv_obj_set_width(cont, LV_PCT(100));
lv_obj_set_flex_grow(cont, 1);
lv_obj_set_layout(cont, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(cont, uiRowGap(), 0);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
const lv_font_t* fnt = lvgl_get_text_font(uiFont());
lblXY_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblXY_, fnt, 0);
lblButton_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblButton_, fnt, 0);
joyArea_ = JOY_AREA;
dotSize_ = DOT_SIZE;
// Visual joystick area
joyCont_ = lv_obj_create(cont);
lv_obj_set_size(joyCont_, JOY_AREA, JOY_AREA);
lv_obj_set_style_bg_color(joyCont_, lv_color_hex(0x222222), 0);
lv_obj_set_style_radius(joyCont_, JOY_AREA / 2, 0);
lv_obj_set_style_border_width(joyCont_, 2, 0);
lv_obj_set_style_pad_all(joyCont_, 0, 0);
lv_obj_remove_flag(joyCont_, LV_OBJ_FLAG_SCROLLABLE);
dot_ = lv_obj_create(joyCont_);
lv_obj_set_size(dot_, DOT_SIZE, DOT_SIZE);
lv_obj_set_style_radius(dot_, DOT_SIZE / 2, 0);
lv_obj_set_style_bg_color(dot_, lv_color_hex(0x00FF00), 0);
lv_obj_set_style_border_width(dot_, 0, 0);
lv_obj_set_pos(dot_, (JOY_AREA - DOT_SIZE) / 2, (JOY_AREA - DOT_SIZE) / 2);
Device* i2c = device_find_by_name("i2c1");
if (!i2c) {
lv_label_set_text(lblXY_, "i2c1 not found");
return;
}
if (unit_.begin(i2c)) {
usingPaHub_ = false;
} else if (hub_.begin(i2c)) {
usingPaHub_ = true;
bool found = false;
for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) {
hub_.select(ch);
if (unit_.begin(i2c)) found = true;
}
if (!found) {
hub_.deselect();
lv_label_set_text(lblXY_, "Joystick2 not found");
return;
}
} else {
lv_label_set_text(lblXY_, "Joystick2 not found");
return;
}
timer_ = lv_timer_create(onTimer, 50, this);
update();
}
void TestUnitJoystick2::onStop() {
if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; }
selectIfNeeded();
if (unit_.isPresent()) unit_.setLed(0x000000);
if (usingPaHub_ && hub_.isPresent()) hub_.deselect();
lblXY_ = lblButton_ = dot_ = joyCont_ = nullptr;
}
void TestUnitJoystick2::selectIfNeeded() {
if (usingPaHub_ && hub_.isPresent())
hub_.select(hub_.currentChannel());
}
void TestUnitJoystick2::onTimer(lv_timer_t* t) {
static_cast<TestUnitJoystick2*>(lv_timer_get_user_data(t))->update();
}
void TestUnitJoystick2::update() {
selectIfNeeded();
if (!unit_.isPresent()) return;
int16_t x = 0, y = 0;
unit_.readXY12(&x, &y);
bool pressed = unit_.isPressed();
lv_label_set_text_fmt(lblXY_, "X: %d Y: %d", (int)x, (int)y);
lv_label_set_text_fmt(lblButton_, "Button: %s", pressed ? "PRESSED" : "released");
// Map ±2048 joystick range to dot position within a circle.
// Work in float to do circular clamping, then snap back to int pixels.
// Negate both axes to match LVGL screen coordinates and joystick orientation.
// Grove connector facing away from the user.
float radius = (float)(joyArea_ - dotSize_) / 2.0f;
float nx = -(float)x / 2048.0f; // normalised -1..1
float ny = -(float)y / 2048.0f; // normalised -1..1
float dist2 = nx * nx + ny * ny;
if (dist2 > 1.0f) {
float inv = 1.0f / std::sqrt(dist2);
nx *= inv;
ny *= inv;
}
int cx = (int)(radius + nx * radius);
int cy = (int)(radius + ny * radius);
lv_obj_set_pos(dot_, cx, cy);
// LED: hue cycles with X position, blue when pressed
if (pressed) {
unit_.setLed(0x0000FF);
} else {
uint16_t hue = (uint16_t)((int)x * 360 / 4096 + 180); // map -2048..2048 -> 0..360
lv_color_t c = lv_color_hsv_to_rgb(hue % 360, 100, 78);
lv_color32_t c32 = lv_color_to_32(c, LV_OPA_COVER);
unit_.setLed(((uint32_t)c32.red << 16) | ((uint32_t)c32.green << 8) | c32.blue);
}
lv_obj_set_style_bg_color(dot_, pressed ? lv_color_hex(0xFF4400) : lv_color_hex(0x00FF00), 0);
}
@@ -0,0 +1,26 @@
#pragma once
#include "TestViewBase.h"
#include <UnitJoystick2.h>
#include <UnitPaHub.h>
class TestUnitJoystick2 final : public TestViewBase {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override;
void onStop() override;
private:
UnitPaHub hub_;
UnitJoystick2 unit_;
lv_obj_t* lblXY_ = nullptr;
lv_obj_t* lblButton_ = nullptr;
lv_obj_t* dot_ = nullptr;
lv_obj_t* joyCont_ = nullptr;
lv_timer_t* timer_ = nullptr;
bool usingPaHub_ = false;
int joyArea_ = 120;
int dotSize_ = 16;
void selectIfNeeded();
static void onTimer(lv_timer_t* t);
void update();
};
+192
View File
@@ -0,0 +1,192 @@
#include "TestUnitLcd.h"
#include "UiScale.h"
#include <tactility/device.h>
#include <tactility/lvgl_fonts.h>
void TestUnitLcd::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
rotation_ = 0;
usingPaHub_ = false;
createToolbar(parent, handle, "Color LCD");
createBanner(parent, "Color LCD", "I2C", COLOR_I2C);
lv_obj_t* cont = lv_obj_create(parent);
lv_obj_set_width(cont, LV_PCT(100));
lv_obj_set_flex_grow(cont, 1);
lv_obj_set_layout(cont, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(cont, uiPad(), 0);
lv_obj_set_style_pad_row(cont, uiRowGap(), 0);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
const lv_font_t* fnt = lvgl_get_text_font(uiFont());
lblStatus_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblStatus_, fnt, 0);
// Brightness row
lv_obj_t* brRow = lv_obj_create(cont);
lv_obj_set_width(brRow, LV_PCT(100));
lv_obj_set_height(brRow, LV_SIZE_CONTENT);
lv_obj_set_layout(brRow, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(brRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(brRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(brRow, 0, 0);
lv_obj_set_style_bg_opa(brRow, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(brRow, 0, 0);
lv_obj_t* brLbl = lv_label_create(brRow);
lv_label_set_text(brLbl, "Bright:");
lv_obj_set_style_text_font(brLbl, fnt, 0);
lv_obj_set_width(brLbl, LV_SIZE_CONTENT);
sliderBr_ = lv_slider_create(brRow);
lv_slider_set_range(sliderBr_, 0, 255);
lv_slider_set_value(sliderBr_, 128, LV_ANIM_OFF);
lv_obj_set_flex_grow(sliderBr_, 1);
lv_obj_add_event_cb(sliderBr_, onBrightnessChanged, LV_EVENT_VALUE_CHANGED, this);
// Rotation
lblRotation_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblRotation_, fnt, 0);
lv_obj_t* btnRot = lv_button_create(cont);
lv_obj_add_event_cb(btnRot, onRotateClicked, LV_EVENT_CLICKED, this);
lv_obj_t* lbl = lv_label_create(btnRot);
lv_label_set_text(lbl, "Rotate 90");
lv_obj_set_style_text_font(lbl, fnt, 0);
// Fill buttons
lv_obj_t* fillRow = lv_obj_create(cont);
lv_obj_set_width(fillRow, LV_PCT(100));
lv_obj_set_height(fillRow, LV_SIZE_CONTENT);
lv_obj_set_layout(fillRow, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(fillRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(fillRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(fillRow, 8, 0);
lv_obj_set_style_bg_opa(fillRow, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(fillRow, 0, 0);
lv_obj_set_style_pad_all(fillRow, 0, 0);
lv_obj_t* btnRed = lv_button_create(fillRow);
lv_obj_add_event_cb(btnRed, onFillRedClicked, LV_EVENT_CLICKED, this);
lv_obj_t* lRed = lv_label_create(btnRed);
lv_label_set_text(lRed, "Fill Red");
lv_obj_set_style_text_font(lRed, fnt, 0);
lv_obj_t* btnBlue = lv_button_create(fillRow);
lv_obj_add_event_cb(btnBlue, onFillBlueClicked, LV_EVENT_CLICKED, this);
lv_obj_t* lBlue = lv_label_create(btnBlue);
lv_label_set_text(lBlue, "Fill Blue");
lv_obj_set_style_text_font(lBlue, fnt, 0);
// Text test button
lv_obj_t* btnText = lv_button_create(cont);
lv_obj_add_event_cb(btnText, onWriteTextClicked, LV_EVENT_CLICKED, this);
lv_obj_t* lText = lv_label_create(btnText);
lv_label_set_text(lText, "Write Text");
lv_obj_set_style_text_font(lText, fnt, 0);
Device* i2c = device_find_by_name("i2c1");
if (!i2c) {
lv_label_set_text(lblStatus_, "i2c1 not found");
return;
}
// Try standalone first, then scan PaHub channels
if (lcd_.begin(i2c)) {
usingPaHub_ = false;
} else if (hub_.begin(i2c)) {
usingPaHub_ = true;
bool found = false;
for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) {
hub_.select(ch);
if (lcd_.begin(i2c)) { found = true; lcdChannel_ = ch; }
}
if (!found) {
hub_.deselect();
lv_label_set_text(lblStatus_, "LCD Unit not found");
return;
}
} else {
lv_label_set_text(lblStatus_, "LCD Unit not found");
return;
}
lv_label_set_text(lblStatus_, "LCD ready");
lv_label_set_text_fmt(lblRotation_, "Rotation: %d (portrait)", (int)rotation_);
lcd_.setBrightness(128);
lcd_.fillScreen(0x0000);
}
void TestUnitLcd::selectIfNeeded() {
if (usingPaHub_ && hub_.isPresent())
hub_.select(lcdChannel_);
}
void TestUnitLcd::onStop() {
selectIfNeeded();
if (lcd_.isPresent()) lcd_.setBrightness(0);
if (usingPaHub_ && hub_.isPresent()) hub_.deselect();
lblStatus_ = sliderBr_ = lblRotation_ = nullptr;
}
void TestUnitLcd::onBrightnessChanged(lv_event_t* e) {
auto* self = static_cast<TestUnitLcd*>(lv_event_get_user_data(e));
self->selectIfNeeded();
if (!self->lcd_.isPresent()) return;
self->lcd_.setBrightness((uint8_t)lv_slider_get_value(self->sliderBr_));
}
void TestUnitLcd::onRotateClicked(lv_event_t* e) {
auto* self = static_cast<TestUnitLcd*>(lv_event_get_user_data(e));
self->selectIfNeeded();
if (!self->lcd_.isPresent()) return;
self->rotation_ = (self->rotation_ + 1) & 0x03;
self->lcd_.setRotation(self->rotation_);
const char* names[] = { "0 (portrait)", "1 (landscape)", "2 (portrait flip)", "3 (landscape flip)" };
lv_label_set_text_fmt(self->lblRotation_, "Rotation: %s", names[self->rotation_]);
}
void TestUnitLcd::onFillRedClicked(lv_event_t* e) {
auto* self = static_cast<TestUnitLcd*>(lv_event_get_user_data(e));
self->selectIfNeeded();
if (!self->lcd_.isPresent()) return;
self->lcd_.fillScreen(UnitLcd::rgb888to565(0xFF0000));
}
void TestUnitLcd::onFillBlueClicked(lv_event_t* e) {
auto* self = static_cast<TestUnitLcd*>(lv_event_get_user_data(e));
self->selectIfNeeded();
if (!self->lcd_.isPresent()) return;
self->lcd_.fillScreen(UnitLcd::rgb888to565(0x0000FF));
}
void TestUnitLcd::onWriteTextClicked(lv_event_t* e) {
auto* self = static_cast<TestUnitLcd*>(lv_event_get_user_data(e));
self->selectIfNeeded();
if (!self->lcd_.isPresent()) return;
self->lcd_.fillScreen(0x0000);
uint16_t white = UnitLcd::rgb888to565(0xFFFFFF);
uint16_t yellow = UnitLcd::rgb888to565(0xFFFF00);
uint16_t cyan = UnitLcd::rgb888to565(0x00FFFF);
uint16_t red = UnitLcd::rgb888to565(0xFF4444);
uint16_t black = 0x0000;
uint16_t h = self->lcd_.height();
uint16_t w = self->lcd_.width();
// 5x7 bitmap font: 1 char = 7px tall, 6px wide; scale-1 row pitch = 9px (7+2 gap)
static constexpr uint16_t FONT1_ROW_H = 9; // row height at scale 1
static constexpr uint16_t FONT1_CHAR_W = 6; // char advance at scale 1
static constexpr uint16_t FONT1_COL_W = FONT1_CHAR_W * 2 + 1; // "R" + margin
int16_t bottomY = (h > FONT1_ROW_H) ? (int16_t)(h - FONT1_ROW_H) : 0;
int16_t middleY = (int16_t)(h / 2);
int16_t rightX = (w > FONT1_COL_W) ? (int16_t)(w - FONT1_COL_W) : 0;
// Scale-2 text: 14px tall, 12px pitch
self->lcd_.drawText(4, 8, "HELLO", yellow, black, 2);
self->lcd_.drawText(4, 32, "WORLD", cyan, black, 2);
self->lcd_.drawText(4, 4, "TOP-LEFT", white, black, 1);
self->lcd_.drawText(4, bottomY, "BOTTOM", red, black, 1);
self->lcd_.drawText(4, middleY, "MIDDLE", white, black, 1);
// Right-side marker so portrait/landscape are visually distinct
self->lcd_.drawText(rightX, 4, "R", white, black, 1);
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "TestViewBase.h"
#include <UnitLcd.h>
#include <UnitPaHub.h>
class TestUnitLcd final : public TestViewBase {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override;
void onStop() override;
private:
UnitPaHub hub_;
UnitLcd lcd_;
lv_obj_t* lblStatus_ = nullptr;
lv_obj_t* sliderBr_ = nullptr;
lv_obj_t* lblRotation_ = nullptr;
uint8_t rotation_ = 0;
bool usingPaHub_ = false;
uint8_t lcdChannel_ = 0;
void selectIfNeeded();
static void onBrightnessChanged(lv_event_t* e);
static void onRotateClicked(lv_event_t* e);
static void onFillRedClicked(lv_event_t* e);
static void onFillBlueClicked(lv_event_t* e);
static void onWriteTextClicked(lv_event_t* e);
};
@@ -0,0 +1,428 @@
#include "TestUnitLcdGfx.h"
#include "UiScale.h"
#include <tactility/device.h>
#include <tactility/lvgl_fonts.h>
#include <esp_timer.h>
#include <cstring>
#include <cstdio>
#include <algorithm>
static inline uint32_t usNow() {
return (uint32_t)esp_timer_get_time();
}
static const char* PHASE_NAMES[] = {
"Screen fill",
"Text",
"Pixels",
"Lines",
"H/V Lines",
"Rectangles (filled)",
"Rectangles (outline)",
"Triangles (filled)",
"Triangles (outline)",
"Circles (filled)",
"Circles (outline)",
"Arcs (filled)",
"Arcs (outline)",
"Round rects (filled)",
"Round rects (outline)",
"Results",
};
static constexpr int PHASE_COUNT = (int)(sizeof(PHASE_NAMES) / sizeof(PHASE_NAMES[0]));
// ---------------------------------------------------------------------------
void TestUnitLcdGfx::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
phase_ = 0;
logBuf_[0] = '\0';
memset(results_, 0, sizeof(results_));
createToolbar(parent, handle, "LCD Gfx Test");
createBanner(parent, "LCD Gfx", "I2C", COLOR_I2C);
lv_obj_t* cont = lv_obj_create(parent);
lv_obj_set_width(cont, LV_PCT(100));
lv_obj_set_flex_grow(cont, 1);
lv_obj_set_layout(cont, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(cont, uiPad(), 0);
lv_obj_set_style_pad_row(cont, uiRowGap(), 0);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
const lv_font_t* fnt = lvgl_get_text_font(uiFont());
const lv_font_t* fntS = lvgl_get_text_font(FONT_SIZE_SMALL);
lblPhase_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblPhase_, fnt, 0);
lv_label_set_text(lblPhase_, "Searching...");
lblLog_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblLog_, fntS, 0);
lv_label_set_long_mode(lblLog_, LV_LABEL_LONG_WRAP);
lv_obj_set_width(lblLog_, LV_PCT(100));
lv_label_set_text(lblLog_, "");
Device* i2c = device_find_by_name("i2c1");
if (!i2c) { lv_label_set_text(lblPhase_, "i2c1 not found"); return; }
if (lcd_.begin(i2c)) {
usingPaHub_ = false;
} else if (hub_.begin(i2c)) {
usingPaHub_ = true;
bool found = false;
for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) {
hub_.select(ch);
if (lcd_.begin(i2c)) found = true;
}
if (!found) {
hub_.deselect();
lv_label_set_text(lblPhase_, "LCD not found");
return;
}
} else {
lv_label_set_text(lblPhase_, "LCD not found");
return;
}
lcd_.setBrightness(180);
lcd_.setRotation(0);
// Pre-compute layout constants
w_ = (int16_t)lcd_.width();
h_ = (int16_t)lcd_.height();
minDim_ = std::min(w_, h_);
minDim1_= minDim_ - 1;
cx_ = w_ / 2;
cy_ = h_ / 2;
cx1_ = cx_ - 1;
cy1_ = cy_ - 1;
cMin_ = std::min(cx1_, cy1_);
cMin1_ = cMin_ - 1;
lv_label_set_text(lblPhase_, PHASE_NAMES[0]);
timer_ = lv_timer_create(onTimer, 200, this);
lv_timer_set_repeat_count(timer_, 1);
}
void TestUnitLcdGfx::onStop() {
if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; }
selectIfNeeded();
if (lcd_.isPresent()) lcd_.setBrightness(0);
if (usingPaHub_ && hub_.isPresent()) hub_.deselect();
lblPhase_ = lblLog_ = nullptr;
}
void TestUnitLcdGfx::selectIfNeeded() {
if (usingPaHub_ && hub_.isPresent())
hub_.select(hub_.currentChannel());
}
void TestUnitLcdGfx::onTimer(lv_timer_t* t) {
static_cast<TestUnitLcdGfx*>(lv_timer_get_user_data(t))->runNextPhase();
}
void TestUnitLcdGfx::appendLog(const char* name, uint32_t us) {
size_t len = strlen(logBuf_);
size_t rem = sizeof(logBuf_) - len;
snprintf(logBuf_ + len, rem, "%-20s %lu\n", name, (unsigned long)us);
lv_label_set_text(lblLog_, logBuf_);
}
void TestUnitLcdGfx::runNextPhase() {
timer_ = nullptr;
if (!lcd_.isPresent()) return;
selectIfNeeded();
if (phase_ >= PHASE_COUNT) { lv_label_set_text(lblPhase_, "Done!"); return; }
lv_label_set_text(lblPhase_, PHASE_NAMES[phase_]);
if (phase_ < PHASE_COUNT - 1) {
// Benchmark phase
uint32_t us = 0;
switch (phase_) {
case 0: us = testFillScreen(); break;
case 1: us = testText(); break;
case 2: us = testPixels(); break;
case 3: us = testLines(); break;
case 4: us = testFastLines(); break;
case 5: us = testFilledRects(); break;
case 6: us = testRects(); break;
case 7: us = testFilledTriangles(); break;
case 8: us = testTriangles(); break;
case 9: us = testFilledCircles(); break;
case 10: us = testCircles(); break;
case 11: us = testFillArcs(); break;
case 12: us = testArcs(); break;
case 13: us = testFilledRoundRects(); break;
case 14: us = testRoundRects(); break;
}
results_[phase_] = us;
appendLog(PHASE_NAMES[phase_], us);
} else {
// Results screen on the LCD itself
drawResultsOnLcd();
lv_label_set_text(lblPhase_, "Done!");
phase_++;
return;
}
phase_++;
// Short pause between phases so the LCD buffer drains
timer_ = lv_timer_create(onTimer, 80, this);
lv_timer_set_repeat_count(timer_, 1);
}
// Draw the timing summary on the LCD unit itself, matching PDQ's results screen.
// PDQ background: c cycles 4..11 and is used directly as an RGB565 value
// (these are near-black blues: 0x0004..0x000B). The subtle banding effect is
// identical to the Arduino PDQ sketch's final loop.
void TestUnitLcdGfx::drawResultsOnLcd() {
uint16_t cyan = UnitLcd::rgb888to565(0x00FFFF);
uint16_t yellow = UnitLcd::rgb888to565(0xFFFF00);
uint16_t green = UnitLcd::rgb888to565(0x00FF00);
uint16_t magenta = UnitLcd::rgb888to565(0xFF00FF);
uint16_t W = lcd_.width(), H = lcd_.height();
// PDQ blue-band background - c is a raw RGB565 value cycling 4..11
{
uint16_t c = 4;
int8_t d = 1;
for (uint16_t i = 0; i < H; i++) {
lcd_.drawHLine(0, (uint8_t)i, (uint8_t)W, c);
c = (uint16_t)(c + d);
if (c <= 4 || c >= 11) d = -d;
}
}
// Title - "LCD GFX PDQ" in magenta (PDQ uses "Arduino GFX PDQ")
uint8_t y = 2;
lcd_.drawText(2, y, "LCD GFX PDQ", magenta, 0x0006, 1); y += 10;
// Header line - green, matching PDQ's "\nBenchmark micro-secs"
lcd_.drawText(2, y, "Benchmark micro-secs", green, 0x0006, 1); y += 10;
// Results - cyan label + yellow number, one per row, 9px line height
// Names padded to 12 chars; number right-aligned in 9 chars (matches PDQ comma style)
static const char* SHORT_NAMES[] = {
"Screen fill",
"Text ",
"Pixels ",
"Lines ",
"H/V Lines ",
"Rectangles F",
"Rectangles ",
"Triangles F",
"Triangles ",
"Circles F ",
"Circles ",
"Arcs F ",
"Arcs ",
"RoundRects F",
"RoundRects ",
};
for (int i = 0; i < RESULT_COUNT; i++) {
if ((int)y + 9 > (int)H - 9) break;
// Label in cyan
lcd_.drawText(2, y, SHORT_NAMES[i], cyan, 0x0006, 1);
// Number in yellow, formatted with commas like PDQ's printnice()
char num[14];
snprintf(num, sizeof(num), "%lu", (unsigned long)results_[i]);
// Insert commas right-to-left (PDQ style)
for (char* p = (num + strlen(num)) - 3; p > num; p -= 3) {
memmove(p + 1, p, strlen(p) + 1);
*p = ',';
}
// Right-align in the remaining width (screen is 135px, label ~72px, 63px left)
// Draw at fixed x so numbers line up
lcd_.drawText(74, y, num, yellow, 0x0006, 1);
y += 9;
}
lcd_.drawText(2, (uint8_t)(H - 9), "Benchmark Complete!", green, 0x0006, 1);
}
// ---------------------------------------------------------------------------
// Benchmark phases - matching PDQ exactly
// ---------------------------------------------------------------------------
uint32_t TestUnitLcdGfx::testFillScreen() {
uint32_t s = usNow();
lcd_.fillScreen(0xFFFF);
lcd_.fillScreen(0xF800);
lcd_.fillScreen(0x07E0);
lcd_.fillScreen(0x001F);
lcd_.fillScreen(0x0000);
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testText() {
// Mirror PDQ testText() - for a 135px wide screen tsa/tsb/tsc all = 1.
// Scale 2 is used once at the end (fits: "Size 2" = 6 chars × 12px = 72px).
uint16_t black = 0x0000;
lcd_.fillScreen(black);
uint32_t s = usNow();
uint8_t y = 0;
lcd_.drawText(0, y, "Hello World!", 0xFFFF, black, 1); y += 9;
lcd_.drawText(0, y, "RED GREEN BLUE", UnitLcd::color565(255,0,0), black, 1); y += 9;
lcd_.drawText(0, y, "1234.56", UnitLcd::color565(255,255,0), black, 1); y += 9;
lcd_.drawText(0, y, "0xDEADBEEF", 0xFFFF, black, 1); y += 9;
lcd_.drawText(0, y, "Groop,", UnitLcd::color565(0,255,255), black, 1); y += 9;
lcd_.drawText(0, y, "I implore thee,", UnitLcd::color565(255,0,255), black, 1); y += 9;
lcd_.drawText(0, y, "my foonting", UnitLcd::color565(0,0,200), black, 1); y += 9;
lcd_.drawText(0, y, "turlingdromes.", UnitLcd::color565(0,128,0), black, 1); y += 9;
lcd_.drawText(0, y, "crinkly bindlewurdles",UnitLcd::color565(0,128,128), black, 1); y += 9;
lcd_.drawText(0, y, "Or I will rend thee", UnitLcd::color565(128,0,0), black, 1); y += 9;
lcd_.drawText(0, y, "gobberwartsb", UnitLcd::color565(128,0,128), black, 1); y += 9;
lcd_.drawText(0, y, "blurglecruncheon,", UnitLcd::color565(128,128,0), black, 1); y += 9;
lcd_.drawText(0, y, "see if I don't!", UnitLcd::color565(64,64,64), black, 1); y += 9;
lcd_.drawText(0, y, "Size 2", UnitLcd::color565(255,0,0), black, 2); y += 18;
lcd_.drawText(0, y, "Size 3", UnitLcd::color565(255,165,0), black, 2); // capped at 2
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testPixels() {
lcd_.fillScreen(0x0000);
uint32_t s = usNow();
for (int16_t y = 0; y < h_; y++) {
for (int16_t x = 0; x < w_; x++) {
lcd_.drawPixel((uint8_t)x, (uint8_t)y,
UnitLcd::color565((uint8_t)(x << 3), (uint8_t)(y << 3),
(uint8_t)((x * y) & 0xFF)));
}
}
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testLines() {
uint16_t blue = 0x001F;
lcd_.fillScreen(0x0000);
uint32_t s = usNow();
// All 4 corners x 2 sweeps each (matching PDQ exactly)
for (int16_t x = 0; x < w_; x += 6) lcd_.drawLine(0, 0, x, h_-1, blue);
for (int16_t y = 0; y < h_; y += 6) lcd_.drawLine(0, 0, w_-1, y, blue);
lcd_.fillScreen(0x0000);
for (int16_t x = 0; x < w_; x += 6) lcd_.drawLine(w_-1, 0, x, h_-1, blue);
for (int16_t y = 0; y < h_; y += 6) lcd_.drawLine(w_-1, 0, 0, y, blue);
lcd_.fillScreen(0x0000);
for (int16_t x = 0; x < w_; x += 6) lcd_.drawLine(0, h_-1, x, 0, blue);
for (int16_t y = 0; y < h_; y += 6) lcd_.drawLine(0, h_-1, w_-1, y, blue);
lcd_.fillScreen(0x0000);
for (int16_t x = 0; x < w_; x += 6) lcd_.drawLine(w_-1, h_-1, x, 0, blue);
for (int16_t y = 0; y < h_; y += 6) lcd_.drawLine(w_-1, h_-1, 0, y, blue);
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testFastLines() {
lcd_.fillScreen(0x0000);
uint32_t s = usNow();
for (int16_t y = 0; y < h_; y += 5) lcd_.drawHLine(0, (uint8_t)y, (uint8_t)w_, 0xF800);
for (int16_t x = 0; x < w_; x += 5) lcd_.drawVLine((uint8_t)x, 0, (uint8_t)h_, 0x001F);
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testFilledRects() {
lcd_.fillScreen(0x0000);
uint32_t s = usNow();
for (int16_t i = minDim_; i > 0; i -= 6) {
int16_t half = i / 2;
lcd_.fillRect((uint8_t)(cx_ - half), (uint8_t)(cy_ - half),
(uint8_t)(cx_ + half - 1), (uint8_t)(cy_ + half - 1),
UnitLcd::color565((uint8_t)std::min((int)i, 255),
(uint8_t)std::min((int)i, 255), 0));
}
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testRects() {
// Don't clear - runs on top of filled rects (matches PDQ)
uint32_t s = usNow();
for (int16_t i = 2; i < minDim_; i += 6) {
int16_t half = i / 2;
lcd_.drawRect((uint8_t)(cx_ - half), (uint8_t)(cy_ - half),
(uint8_t)i, (uint8_t)i, 0x07E0);
}
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testFilledTriangles() {
lcd_.fillScreen(0x0000);
uint32_t s = usNow();
for (int16_t i = cMin1_; i > 10; i -= 5) {
lcd_.fillTriangle(cx1_, cy1_ - i, cx1_ - i, cy1_ + i, cx1_ + i, cy1_ + i,
UnitLcd::color565(0, (uint8_t)std::min(i*2, 255), (uint8_t)std::min(i*2, 255)));
}
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testTriangles() {
// Don't clear - runs on top (matches PDQ)
uint32_t s = usNow();
for (int16_t i = 0; i < cMin_; i += 5) {
lcd_.drawTriangle(cx1_, cy1_ - i, cx1_ - i, cy1_ + i, cx1_ + i, cy1_ + i,
UnitLcd::color565(0, 0, (uint8_t)std::min(i*4, 255)));
}
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testFilledCircles() {
lcd_.fillScreen(0x0000);
uint32_t s = usNow();
for (int16_t x = 10; x < (int16_t)w_; x += 20)
for (int16_t y = 10; y < (int16_t)h_; y += 20)
lcd_.fillCircle(x, y, 10, 0xF81F);
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testCircles() {
// Don't clear (matches PDQ)
uint32_t s = usNow();
for (int16_t x = 0; x <= (int16_t)w_ + 10; x += 20)
for (int16_t y = 0; y <= (int16_t)h_ + 10; y += 20)
lcd_.drawCircle(x, y, 10, 0xFFFF);
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testFillArcs() {
lcd_.fillScreen(0x0000);
int16_t r = (cMin_ > 0) ? (360 / cMin_) : 6;
uint32_t s = usNow();
for (int16_t i = 6; i < cMin_; i += 6)
lcd_.fillArc(cx1_, cy1_, i, i - 3, 0.0f, (float)(i * r), 0xF800);
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testArcs() {
// Don't clear (matches PDQ)
int16_t r = (cMin_ > 0) ? (360 / cMin_) : 6;
uint32_t s = usNow();
for (int16_t i = 6; i < cMin_; i += 6)
lcd_.drawArc(cx1_, cy1_, i, i - 3, 0.0f, (float)(i * r), 0xFFFF);
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testFilledRoundRects() {
lcd_.fillScreen(0x0000);
uint32_t s = usNow();
for (int16_t i = minDim1_; i > 20; i -= 6) {
int16_t half = i / 2;
lcd_.fillRoundRect(cx_ - half, cy_ - half, i, i, i / 8,
UnitLcd::color565(0, (uint8_t)std::min(i*2, 255), 0));
}
return usNow() - s;
}
uint32_t TestUnitLcdGfx::testRoundRects() {
// Don't clear (matches PDQ)
uint32_t s = usNow();
for (int16_t i = 20; i < minDim1_; i += 6) {
int16_t half = i / 2;
lcd_.drawRoundRect(cx_ - half, cy_ - half, i, i, i / 8,
UnitLcd::color565((uint8_t)std::min(i*2, 255), 0, 0));
}
return usNow() - s;
}
@@ -0,0 +1,58 @@
#pragma once
#include "TestViewBase.h"
#include <UnitLcd.h>
#include <UnitPaHub.h>
// PDQ graphics benchmark test, matching the Arduino_GFX PDQgraphicstest sketch.
// Phases run sequentially via one-shot LVGL timers; results appear in the LVGL
// log and are also drawn back onto the LCD unit at the end.
class TestUnitLcdGfx final : public TestViewBase {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override;
void onStop() override;
private:
UnitPaHub hub_;
UnitLcd lcd_;
bool usingPaHub_ = false;
lv_obj_t* lblPhase_ = nullptr;
lv_obj_t* lblLog_ = nullptr;
lv_timer_t* timer_ = nullptr;
static constexpr int RESULT_COUNT = 15;
int phase_ = 0;
char logBuf_[768] = {};
uint32_t results_[RESULT_COUNT] = {};
// Pre-computed layout constants (set in onStart after lcd_.begin)
int16_t w_ = 0, h_ = 0;
int16_t minDim_ = 0, minDim1_ = 0; // min(w,h) and min(w,h)-1
int16_t cx_ = 0, cy_ = 0, cx1_ = 0, cy1_ = 0;
int16_t cMin_ = 0, cMin1_ = 0; // min(cx1,cy1) and min(cx1,cy1)-1
void selectIfNeeded();
void runNextPhase();
void appendLog(const char* name, uint32_t us);
void drawResultsOnLcd();
static void onTimer(lv_timer_t* t);
// Benchmark phases - matching PDQ order exactly
uint32_t testFillScreen();
uint32_t testText();
uint32_t testPixels();
uint32_t testLines();
uint32_t testFastLines();
uint32_t testFilledRects();
uint32_t testRects();
uint32_t testFilledTriangles();
uint32_t testTriangles();
uint32_t testFilledCircles();
uint32_t testCircles();
uint32_t testFillArcs();
uint32_t testArcs();
uint32_t testFilledRoundRects();
uint32_t testRoundRects();
};
@@ -0,0 +1,167 @@
#include "TestUnitMidi.h"
#include "UiScale.h"
#include <tactility/device.h>
#include <tactility/lvgl_fonts.h>
void TestUnitMidi::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
createToolbar(parent, handle, "MIDI / Synth");
createBanner(parent, "MIDI", "UART", COLOR_UART);
lv_obj_t* cont = lv_obj_create(parent);
lv_obj_set_width(cont, LV_PCT(100));
lv_obj_set_flex_grow(cont, 1);
lv_obj_set_layout(cont, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(cont, uiPad(), 0);
lv_obj_set_style_pad_row(cont, uiRowGap(), 0);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
const lv_font_t* fnt = lvgl_get_text_font(uiFont());
const lv_font_t* fntS = lvgl_get_text_font(FONT_SIZE_SMALL);
lblStatus_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblStatus_, fnt, 0);
lblChannel_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblChannel_, fntS, 0);
lblProgram_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblProgram_, fntS, 0);
// Channel row
auto makeAdjRow = [&](const char* name, lv_event_cb_t down, lv_event_cb_t up) {
lv_obj_t* row = lv_obj_create(cont);
lv_obj_set_width(row, LV_PCT(100));
lv_obj_set_height(row, LV_SIZE_CONTENT);
lv_obj_set_layout(row, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_set_style_pad_column(row, 8, 0);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_t* lbl = lv_label_create(row);
lv_label_set_text(lbl, name);
lv_obj_set_style_text_font(lbl, fntS, 0);
lv_obj_set_width(lbl, LV_SIZE_CONTENT);
lv_obj_t* bDown = lv_button_create(row);
lv_obj_add_event_cb(bDown, down, LV_EVENT_CLICKED, this);
lv_obj_t* lD = lv_label_create(bDown); lv_label_set_text(lD, "-");
lv_obj_set_style_text_font(lD, fntS, 0);
lv_obj_t* bUp = lv_button_create(row);
lv_obj_add_event_cb(bUp, up, LV_EVENT_CLICKED, this);
lv_obj_t* lU = lv_label_create(bUp); lv_label_set_text(lU, "+");
lv_obj_set_style_text_font(lU, fntS, 0);
};
makeAdjRow("Channel:", onChDown, onChUp);
makeAdjRow("Program:", onProgDown, onProgUp);
// Note on/off
lv_obj_t* noteRow = lv_obj_create(cont);
lv_obj_set_width(noteRow, LV_PCT(100));
lv_obj_set_height(noteRow, LV_SIZE_CONTENT);
lv_obj_set_layout(noteRow, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(noteRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(noteRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(noteRow, 0, 0);
lv_obj_set_style_pad_column(noteRow, 8, 0);
lv_obj_set_style_bg_opa(noteRow, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(noteRow, 0, 0);
lv_obj_t* btnOn = lv_button_create(noteRow);
lv_obj_add_event_cb(btnOn, onNoteOnClicked, LV_EVENT_CLICKED, this);
lv_obj_t* lOn = lv_label_create(btnOn); lv_label_set_text(lOn, "Note On (C4)");
lv_obj_set_style_text_font(lOn, fntS, 0);
lv_obj_t* btnOff = lv_button_create(noteRow);
lv_obj_add_event_cb(btnOff, onNoteOffClicked, LV_EVENT_CLICKED, this);
lv_obj_t* lOff = lv_label_create(btnOff); lv_label_set_text(lOff, "Note Off");
lv_obj_set_style_text_font(lOff, fntS, 0);
Device* uart = device_find_by_name(UART_DEVICE);
if (!uart || !device_is_ready(uart) || !unit_.begin(uart)) {
lv_label_set_text(lblStatus_, "MIDI UART not available");
updateLabels();
return;
}
lv_label_set_text(lblStatus_, "MIDI ready (31250 bps)");
unit_.programChange(channel_, program_);
updateLabels();
}
void TestUnitMidi::onStop() {
if (notePlaying_ && unit_.isPresent()) {
unit_.noteOff(channel_, note_);
notePlaying_ = false;
}
unit_.end();
lblStatus_ = lblChannel_ = lblProgram_ = nullptr;
}
void TestUnitMidi::updateLabels() {
lv_label_set_text_fmt(lblChannel_, "Channel: %d", (int)channel_ + 1);
lv_label_set_text_fmt(lblProgram_, "Program: %d", (int)program_ + 1);
}
void TestUnitMidi::onNoteOnClicked(lv_event_t* e) {
auto* self = static_cast<TestUnitMidi*>(lv_event_get_user_data(e));
if (!self->unit_.isPresent()) return;
self->unit_.noteOn(self->channel_, self->note_, 100);
self->notePlaying_ = true;
}
void TestUnitMidi::onNoteOffClicked(lv_event_t* e) {
auto* self = static_cast<TestUnitMidi*>(lv_event_get_user_data(e));
if (!self->unit_.isPresent()) return;
self->unit_.noteOff(self->channel_, self->note_);
self->notePlaying_ = false;
}
void TestUnitMidi::onChDown(lv_event_t* e) {
auto* self = static_cast<TestUnitMidi*>(lv_event_get_user_data(e));
if (self->channel_ > 0) {
if (self->notePlaying_ && self->unit_.isPresent()) {
self->unit_.noteOff(self->channel_, self->note_);
self->notePlaying_ = false;
}
self->channel_--;
self->updateLabels();
}
}
void TestUnitMidi::onChUp(lv_event_t* e) {
auto* self = static_cast<TestUnitMidi*>(lv_event_get_user_data(e));
if (self->channel_ < 15) {
if (self->notePlaying_ && self->unit_.isPresent()) {
self->unit_.noteOff(self->channel_, self->note_);
self->notePlaying_ = false;
}
self->channel_++;
self->updateLabels();
}
}
void TestUnitMidi::onProgDown(lv_event_t* e) {
auto* self = static_cast<TestUnitMidi*>(lv_event_get_user_data(e));
if (self->program_ > 0) {
self->program_--;
if (self->unit_.isPresent()) self->unit_.programChange(self->channel_, self->program_);
self->updateLabels();
}
}
void TestUnitMidi::onProgUp(lv_event_t* e) {
auto* self = static_cast<TestUnitMidi*>(lv_event_get_user_data(e));
if (self->program_ < 127) {
self->program_++;
if (self->unit_.isPresent()) self->unit_.programChange(self->channel_, self->program_);
self->updateLabels();
}
}
@@ -0,0 +1,30 @@
#pragma once
#include "TestViewBase.h"
#include <UnitMidi.h>
class TestUnitMidi final : public TestViewBase {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override;
void onStop() override;
private:
UnitMidi unit_;
lv_obj_t* lblStatus_ = nullptr;
lv_obj_t* lblChannel_ = nullptr;
lv_obj_t* lblProgram_ = nullptr;
uint8_t channel_ = 0;
uint8_t program_ = 0;
uint8_t note_ = 60; // middle C
bool notePlaying_= false;
// UART device name for MIDI unit (adjust to match your board wiring)
static constexpr const char* UART_DEVICE = "uart1";
static void onNoteOnClicked(lv_event_t* e);
static void onNoteOffClicked(lv_event_t* e);
static void onChUp(lv_event_t* e);
static void onChDown(lv_event_t* e);
static void onProgUp(lv_event_t* e);
static void onProgDown(lv_event_t* e);
void updateLabels();
};
@@ -0,0 +1,127 @@
#include "TestUnitPaHub.h"
#include "UiScale.h"
#include <tactility/device.h>
#include <tactility/drivers/i2c_controller.h>
#include <tactility/lvgl_fonts.h>
void TestUnitPaHub::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
createToolbar(parent, handle, "PaHub");
createBanner(parent, "PaHub", "I2C", COLOR_I2C);
lv_obj_t* cont = lv_obj_create(parent);
lv_obj_set_width(cont, LV_PCT(100));
lv_obj_set_flex_grow(cont, 1);
lv_obj_set_layout(cont, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(cont, uiPad(), 0);
lv_obj_set_style_pad_row(cont, uiRowGap(), 0);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
const lv_font_t* fnt = lvgl_get_text_font(uiFont());
int pad = uiPad();
int gap = uiRowGap();
// Row of channel buttons — flex-grow so they share space evenly at any width
lv_obj_t* btnRow = lv_obj_create(cont);
lv_obj_set_width(btnRow, LV_PCT(100));
lv_obj_set_height(btnRow, LV_SIZE_CONTENT);
lv_obj_set_layout(btnRow, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(btnRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(btnRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(btnRow, gap, 0);
lv_obj_set_style_bg_opa(btnRow, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(btnRow, 0, 0);
lv_obj_set_style_pad_all(btnRow, 0, 0);
for (int i = 0; i < CH_COUNT; i++) {
lv_obj_t* btn = lv_button_create(btnRow);
lv_obj_set_flex_grow(btn, 1);
lv_obj_set_style_pad_hor(btn, pad, 0);
lv_obj_set_style_pad_ver(btn, gap, 0);
lv_obj_set_user_data(btn, (void*)(intptr_t)i);
lv_obj_add_event_cb(btn, onChannelBtn, LV_EVENT_CLICKED, this);
lv_obj_t* lbl = lv_label_create(btn);
lv_label_set_text_fmt(lbl, "CH%d", i);
lv_obj_set_style_text_font(lbl, fnt, 0);
lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0);
btnCh_[i] = btn;
}
lblStatus_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblStatus_, fnt, 0);
lv_label_set_text(lblStatus_, "Select a channel to probe");
for (int i = 0; i < CH_COUNT; i++) {
lblCh_[i] = lv_label_create(cont);
lv_obj_set_style_text_font(lblCh_[i], fnt, 0);
lv_label_set_text_fmt(lblCh_[i], "CH%d: -", i);
}
Device* i2c = device_find_by_name("i2c1");
if (!i2c || !hub_.begin(i2c)) {
lv_label_set_text(lblStatus_, "PaHub not found");
return;
}
lv_label_set_text(lblStatus_, "PaHub ready - tap channel to probe");
timer_ = lv_timer_create(onTimer, 1000, this);
}
void TestUnitPaHub::onStop() {
if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; }
if (hub_.isPresent()) hub_.deselect();
lblStatus_ = nullptr;
for (int i = 0; i < CH_COUNT; i++) { btnCh_[i] = nullptr; lblCh_[i] = nullptr; }
}
void TestUnitPaHub::onChannelBtn(lv_event_t* e) {
auto* self = static_cast<TestUnitPaHub*>(lv_event_get_user_data(e));
int ch = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target_obj(e));
self->selChannel_ = ch;
self->probeSelected();
}
void TestUnitPaHub::onTimer(lv_timer_t* t) {
auto* self = static_cast<TestUnitPaHub*>(lv_timer_get_user_data(t));
if (self->selChannel_ >= 0) self->probeSelected();
}
void TestUnitPaHub::probeSelected() {
if (!hub_.isPresent() || selChannel_ < 0 || selChannel_ >= CH_COUNT) return;
hub_.select((uint8_t)selChannel_);
// Scan I2C addresses 0x08-0x77 on this channel
Device* i2c = device_find_by_name("i2c1");
if (!i2c) {
lv_label_set_text_fmt(lblCh_[selChannel_], "CH%d: i2c1 not found", selChannel_);
hub_.deselect();
return;
}
char found[256] = "Found: ";
bool any = false;
for (uint8_t addr = 0x08; addr < 0x78; addr++) {
if (i2c_controller_has_device_at_address(i2c, addr,
pdMS_TO_TICKS(10)) == ERROR_NONE) {
size_t remaining = sizeof(found) - strlen(found) - 1;
if (remaining < 7) {
strncat(found, "...", remaining);
break;
}
char hex[8];
snprintf(hex, sizeof(hex), "0x%02X ", addr);
strncat(found, hex, remaining);
any = true;
}
}
if (!any) strcpy(found, "No devices found");
lv_label_set_text_fmt(lblCh_[selChannel_], "CH%d: %s", selChannel_, found);
lv_label_set_text_fmt(lblStatus_, "Probed CH%d", selChannel_);
hub_.deselect();
}
@@ -0,0 +1,23 @@
#pragma once
#include "TestViewBase.h"
#include <UnitPaHub.h>
class TestUnitPaHub final : public TestViewBase {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override;
void onStop() override;
private:
static constexpr int CH_COUNT = UnitPaHub::NUM_CHANNELS;
UnitPaHub hub_;
lv_obj_t* lblStatus_ = nullptr;
lv_obj_t* btnCh_[CH_COUNT] = {};
lv_obj_t* lblCh_[CH_COUNT] = {};
lv_timer_t* timer_ = nullptr;
int selChannel_ = -1;
static void onChannelBtn(lv_event_t* e);
static void onTimer(lv_timer_t* t);
void probeSelected();
};
@@ -0,0 +1,222 @@
#include "TestUnitRfid2.h"
#include "UiScale.h"
#include <tactility/device.h>
#include <tactility/lvgl_fonts.h>
#include <tt_lvgl_toolbar.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
// ---------------------------------------------------------------------------
// onStart
// ---------------------------------------------------------------------------
void TestUnitRfid2::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
createToolbar(parent, handle, "RFID 2");
createBanner(parent, "RFID 2", "I2C", COLOR_I2C);
int pad = uiPad();
int rowGap = uiRowGap();
auto font = uiFont();
bool wide = uiW() >= 240;
// Compute circle diameter: min(availW, availH - toolbar) * 2/3, clamped to 300
lv_coord_t availW = uiW();
lv_coord_t availH = uiH() - 50;
lv_coord_t diam = static_cast<lv_coord_t>(std::min(availW, availH) * 2 / 3);
if (diam > 300) diam = 300;
// Content container - flex column, fills remaining space, centered
lv_obj_t* content = lv_obj_create(parent);
lv_obj_set_width(content, LV_PCT(100));
lv_obj_set_flex_grow(content, 1);
lv_obj_set_layout(content, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(content, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(content, pad, 0);
lv_obj_set_style_pad_row(content, rowGap, 0);
lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(content, 0, 0);
// Idle group
idleGroup_ = lv_obj_create(content);
lv_obj_set_width(idleGroup_, LV_SIZE_CONTENT);
lv_obj_set_height(idleGroup_, LV_SIZE_CONTENT);
lv_obj_set_layout(idleGroup_, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(idleGroup_, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(idleGroup_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(idleGroup_, 0, 0);
lv_obj_set_style_pad_row(idleGroup_, rowGap, 0);
lv_obj_set_style_bg_opa(idleGroup_, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(idleGroup_, 0, 0);
// Green circle
circle_ = lv_obj_create(idleGroup_);
lv_obj_set_size(circle_, diam, diam);
lv_obj_set_style_radius(circle_, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(circle_, LV_COLOR_MAKE(0x20, 0xC0, 0x50), 0);
lv_obj_set_style_bg_opa(circle_, pulseOpa_, 0);
lv_obj_set_style_border_width(circle_, 0, 0);
lv_obj_remove_flag(circle_, LV_OBJ_FLAG_SCROLLABLE);
// "Tap a tag/card..." label
lv_obj_t* tapLabel = lv_label_create(idleGroup_);
lv_obj_set_style_text_font(tapLabel, lvgl_get_text_font(font), 0);
lv_label_set_text(tapLabel, "Tap a tag/card...");
// ── Card info group ───────────────────────────────────────────────────────
cardGroup_ = lv_obj_create(content);
lv_obj_set_width(cardGroup_, LV_PCT(100));
lv_obj_set_height(cardGroup_, LV_SIZE_CONTENT);
lv_obj_set_layout(cardGroup_, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(cardGroup_, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(cardGroup_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_set_style_pad_all(cardGroup_, pad, 0);
lv_obj_set_style_pad_row(cardGroup_, rowGap, 0);
lv_obj_set_style_bg_opa(cardGroup_, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cardGroup_, 0, 0);
lv_obj_add_flag(cardGroup_, LV_OBJ_FLAG_HIDDEN);
lblUid_ = lv_label_create(cardGroup_);
lv_obj_set_style_text_font(lblUid_,
lvgl_get_text_font(wide ? FONT_SIZE_LARGE : FONT_SIZE_DEFAULT), 0);
lv_label_set_text(lblUid_, "");
lblType_ = lv_label_create(cardGroup_);
lv_obj_set_style_text_font(lblType_, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
lv_label_set_text(lblType_, "");
lblSak_ = lv_label_create(cardGroup_);
lv_obj_set_style_text_font(lblSak_, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_label_set_text(lblSak_, "");
lv_obj_t* btnClear = lv_button_create(cardGroup_);
lv_obj_set_style_pad_hor(btnClear, pad * 2, 0);
lv_obj_set_style_pad_ver(btnClear, rowGap, 0);
lv_obj_add_event_cb(btnClear, onClear, LV_EVENT_CLICKED, this);
lv_obj_t* btnLbl = lv_label_create(btnClear);
lv_obj_set_style_text_font(btnLbl, lvgl_get_text_font(font), 0);
lv_label_set_text(btnLbl, "Clear");
// ── Device discovery ─────────────────────────────────────────────────────
Device* i2c = device_find_by_name("i2c1");
if (!i2c) return;
if (unit_.begin(i2c)) {
usingPaHub_ = false;
} else if (hub_.begin(i2c)) {
usingPaHub_ = true;
bool found = false;
for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) {
hub_.select(ch);
if (unit_.begin(i2c)) found = true;
}
if (!found) {
hub_.deselect();
return;
}
} else {
return;
}
timer_ = lv_timer_create(onTimer, 100, this);
pulseTimer_ = lv_timer_create(onPulseTimer, 60, this);
}
// ---------------------------------------------------------------------------
// onStop
// ---------------------------------------------------------------------------
void TestUnitRfid2::onStop() {
if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; }
if (pulseTimer_) { lv_timer_delete(pulseTimer_); pulseTimer_ = nullptr; }
if (usingPaHub_ && hub_.isPresent()) hub_.deselect();
cardShown_ = false;
idleGroup_ = circle_ = nullptr;
cardGroup_ = lblUid_ = lblType_ = lblSak_ = nullptr;
}
// ---------------------------------------------------------------------------
// PaHub helper
// ---------------------------------------------------------------------------
void TestUnitRfid2::selectIfNeeded() {
if (usingPaHub_ && hub_.isPresent())
hub_.select(hub_.currentChannel());
}
// ---------------------------------------------------------------------------
// showCard
// ---------------------------------------------------------------------------
void TestUnitRfid2::showCard(const UnitRfid2::Uid& uid) {
lastUid_ = uid;
cardType_ = unit_.getCardType(uid);
cardShown_ = true;
// UID string
char uidBuf[40] = "UID: ";
int pos = 5;
uint8_t size = (uid.size <= 10) ? uid.size : 10;
for (uint8_t i = 0; i < size; i++)
pos += snprintf(uidBuf + pos, sizeof(uidBuf) - (size_t)pos, "%02X ", uid.bytes[i]);
lv_label_set_text(lblUid_, uidBuf);
char typeBuf[64];
snprintf(typeBuf, sizeof(typeBuf), "Type: %s", unit_.cardTypeName(cardType_));
lv_label_set_text(lblType_, typeBuf);
char sakBuf[40];
snprintf(sakBuf, sizeof(sakBuf), "SAK: %02X ATQA: %02X %02X",
uid.sak, uid.atqa[0], uid.atqa[1]);
lv_label_set_text(lblSak_, sakBuf);
lv_obj_add_flag(idleGroup_, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(cardGroup_, LV_OBJ_FLAG_HIDDEN);
unit_.haltCard();
}
// ---------------------------------------------------------------------------
// Timer callbacks
// ---------------------------------------------------------------------------
void TestUnitRfid2::onTimer(lv_timer_t* t) {
auto* self = static_cast<TestUnitRfid2*>(lv_timer_get_user_data(t));
if (self->cardShown_) return;
self->selectIfNeeded();
if (!self->unit_.isPresent()) return;
UnitRfid2::Uid uid = {};
if (self->unit_.readCard(&uid))
self->showCard(uid);
}
void TestUnitRfid2::onPulseTimer(lv_timer_t* t) {
auto* self = static_cast<TestUnitRfid2*>(lv_timer_get_user_data(t));
if (self->cardShown_ || !self->circle_) return;
self->pulseOpa_ = static_cast<uint8_t>(self->pulseOpa_ + self->pulseDir_);
if (self->pulseOpa_ >= 255) {
self->pulseOpa_ = 255;
self->pulseDir_ = -8;
} else if (self->pulseOpa_ <= 180) {
self->pulseOpa_ = 180;
self->pulseDir_ = 8;
}
lv_obj_set_style_bg_opa(self->circle_, self->pulseOpa_, 0);
}
// ---------------------------------------------------------------------------
// Clear button callback
// ---------------------------------------------------------------------------
void TestUnitRfid2::onClear(lv_event_t* e) {
auto* self = static_cast<TestUnitRfid2*>(lv_event_get_user_data(e));
self->cardShown_ = false;
lv_obj_remove_flag(self->idleGroup_, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(self->cardGroup_, LV_OBJ_FLAG_HIDDEN);
}
@@ -0,0 +1,44 @@
#pragma once
#include "TestViewBase.h"
#include <UnitRfid2.h>
#include <UnitPaHub.h>
#include <tt_app.h>
class TestUnitRfid2 final : public TestViewBase {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override;
void onStop() override;
private:
UnitPaHub hub_;
UnitRfid2 unit_;
lv_timer_t* timer_ = nullptr;
lv_timer_t* pulseTimer_ = nullptr;
bool usingPaHub_ = false;
bool cardShown_ = false;
// Idle group
lv_obj_t* idleGroup_ = nullptr;
lv_obj_t* circle_ = nullptr;
// Card info group
lv_obj_t* cardGroup_ = nullptr;
lv_obj_t* lblUid_ = nullptr;
lv_obj_t* lblType_ = nullptr;
lv_obj_t* lblSak_ = nullptr;
// Pulse state
uint8_t pulseOpa_ = 220;
int8_t pulseDir_ = 8;
// Stored card info
UnitRfid2::Uid lastUid_ = {};
UnitRfid2::CardType cardType_ = UnitRfid2::CardType::Unknown;
void selectIfNeeded();
void showCard(const UnitRfid2::Uid& uid);
static void onTimer(lv_timer_t* t);
static void onPulseTimer(lv_timer_t* t);
static void onClear(lv_event_t* e);
};
@@ -0,0 +1,128 @@
#include "TestUnitScroll.h"
#include "UiScale.h"
#include <tactility/device.h>
#include <tactility/lvgl_fonts.h>
void TestUnitScroll::onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) {
app_ = app;
createToolbar(parent, handle, "Scroll");
createBanner(parent, "Scroll", "I2C", COLOR_I2C);
lv_obj_t* cont = lv_obj_create(parent);
lv_obj_set_width(cont, LV_PCT(100));
lv_obj_set_flex_grow(cont, 1);
lv_obj_set_layout(cont, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(cont, uiPad(), 0);
lv_obj_set_style_pad_row(cont, uiRowGap(), 0);
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cont, 0, 0);
const lv_font_t* fnt = lvgl_get_text_font(uiFont());
lblCounter_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblCounter_, lvgl_get_text_font(uiW() < 200 ? FONT_SIZE_DEFAULT : FONT_SIZE_LARGE), 0);
lblButton_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblButton_, fnt, 0);
lblLed_ = lv_label_create(cont);
lv_obj_set_style_text_font(lblLed_, fnt, 0);
// RGB sliders
auto makeSlider = [&](const char* name) -> lv_obj_t* {
lv_obj_t* row = lv_obj_create(cont);
lv_obj_set_width(row, LV_PCT(100));
lv_obj_set_height(row, LV_SIZE_CONTENT);
lv_obj_set_layout(row, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(row, 0, 0);
lv_obj_t* lbl = lv_label_create(row);
lv_label_set_text(lbl, name);
lv_obj_set_style_text_font(lbl, fnt, 0);
lv_obj_set_width(lbl, LV_SIZE_CONTENT);
lv_obj_t* sl = lv_slider_create(row);
lv_slider_set_range(sl, 0, 255);
lv_slider_set_value(sl, 0, LV_ANIM_OFF);
lv_obj_set_flex_grow(sl, 1);
lv_obj_add_event_cb(sl, onSliderChanged, LV_EVENT_VALUE_CHANGED, this);
return sl;
};
sliderR_ = makeSlider("R");
sliderG_ = makeSlider("G");
sliderB_ = makeSlider("B");
lv_label_set_text(lblLed_, "LED: #000000");
Device* i2c = device_find_by_name("i2c1");
if (!i2c) {
lv_label_set_text(lblCounter_, "i2c1 not found");
return;
}
if (unit_.begin(i2c)) {
usingPaHub_ = false;
} else if (hub_.begin(i2c)) {
usingPaHub_ = true;
bool found = false;
for (uint8_t ch = 0; ch < UnitPaHub::NUM_CHANNELS && !found; ch++) {
hub_.select(ch);
if (unit_.begin(i2c)) found = true;
}
if (!found) {
hub_.deselect();
lv_label_set_text(lblCounter_, "Scroll not found");
return;
}
} else {
lv_label_set_text(lblCounter_, "Scroll not found");
return;
}
timer_ = lv_timer_create(onTimer, 50, this);
update();
}
void TestUnitScroll::onStop() {
if (timer_) { lv_timer_delete(timer_); timer_ = nullptr; }
selectIfNeeded();
if (unit_.isPresent()) unit_.setLed(0x000000);
if (usingPaHub_ && hub_.isPresent()) hub_.deselect();
lblCounter_ = lblButton_ = lblLed_ = nullptr;
sliderR_ = sliderG_ = sliderB_ = nullptr;
}
void TestUnitScroll::selectIfNeeded() {
if (usingPaHub_ && hub_.isPresent())
hub_.select(hub_.currentChannel());
}
void TestUnitScroll::onTimer(lv_timer_t* t) {
static_cast<TestUnitScroll*>(lv_timer_get_user_data(t))->update();
}
void TestUnitScroll::onSliderChanged(lv_event_t* e) {
static_cast<TestUnitScroll*>(lv_event_get_user_data(e))->updateLedFromSliders();
}
void TestUnitScroll::update() {
selectIfNeeded();
if (!unit_.isPresent()) return;
counter_ -= unit_.readDelta();
lv_label_set_text_fmt(lblCounter_, "Counter: %ld", (long)counter_);
lv_label_set_text_fmt(lblButton_, "Button: %s", unit_.isPressed() ? "PRESSED" : "released");
}
void TestUnitScroll::updateLedFromSliders() {
selectIfNeeded();
if (!unit_.isPresent()) return;
uint32_t r = (uint32_t)lv_slider_get_value(sliderR_);
uint32_t g = (uint32_t)lv_slider_get_value(sliderG_);
uint32_t b = (uint32_t)lv_slider_get_value(sliderB_);
uint32_t rgb = (r << 16) | (g << 8) | b;
unit_.setLed(rgb);
lv_label_set_text_fmt(lblLed_, "LED: #%06lX", (unsigned long)rgb);
}
@@ -0,0 +1,29 @@
#pragma once
#include "TestViewBase.h"
#include <UnitScroll.h>
#include <UnitPaHub.h>
class TestUnitScroll final : public TestViewBase {
public:
void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) override;
void onStop() override;
private:
UnitPaHub hub_;
UnitScroll unit_;
lv_obj_t* lblCounter_ = nullptr;
lv_obj_t* lblButton_ = nullptr;
lv_obj_t* lblLed_ = nullptr;
lv_obj_t* sliderR_ = nullptr;
lv_obj_t* sliderG_ = nullptr;
lv_obj_t* sliderB_ = nullptr;
lv_timer_t* timer_ = nullptr;
int32_t counter_ = 0;
bool usingPaHub_ = false;
void selectIfNeeded();
static void onTimer(lv_timer_t* t);
static void onSliderChanged(lv_event_t* e);
void update();
void updateLedFromSliders();
};
@@ -0,0 +1,67 @@
#include "TestViewBase.h"
#include "M5UnitTest.h"
#include "UiScale.h"
#include <tt_lvgl_toolbar.h>
#include <tactility/lvgl_fonts.h>
lv_obj_t* TestViewBase::createToolbar(lv_obj_t* parent, AppHandle handle, const char* title) {
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, handle);
tt_lvgl_toolbar_set_title(toolbar, title);
tt_lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LEFT, onBackClicked, this);
return toolbar;
}
void TestViewBase::createBanner(lv_obj_t* parent, const char* unitName,
const char* ifaceBadge, lv_color_t accentColor) {
lv_coord_t bannerH = uiH() < 200 ? 16 : 22;
lv_obj_t* banner = lv_obj_create(parent);
lv_obj_set_width(banner, LV_PCT(100));
lv_obj_set_height(banner, bannerH);
lv_obj_set_style_bg_color(banner, accentColor, 0);
lv_obj_set_style_bg_opa(banner, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(banner, 0, 0);
lv_obj_set_style_radius(banner, 0, 0);
lv_obj_set_style_pad_hor(banner, 6, 0);
lv_obj_set_style_pad_ver(banner, 0, 0);
lv_obj_set_layout(banner, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(banner, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(banner, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(banner, LV_OBJ_FLAG_SCROLLABLE);
// Unit name label (left-aligned)
lv_obj_t* nameLabel = lv_label_create(banner);
lv_label_set_text(nameLabel, unitName);
lv_obj_set_style_text_color(nameLabel, lv_color_white(), 0);
lv_obj_set_style_text_font(nameLabel, lvgl_get_text_font(uiFont()), 0);
lv_obj_set_width(nameLabel, LV_SIZE_CONTENT);
// Interface badge pill (right-aligned)
lv_obj_t* pill = lv_obj_create(banner);
lv_obj_set_size(pill, LV_SIZE_CONTENT, bannerH - 4);
lv_obj_set_style_bg_color(pill, lv_color_white(), 0);
lv_obj_set_style_bg_opa(pill, LV_OPA_30, 0);
lv_obj_set_style_border_width(pill, 1, 0);
lv_obj_set_style_border_color(pill, lv_color_white(), 0);
lv_obj_set_style_border_opa(pill, LV_OPA_60, 0);
lv_obj_set_style_radius(pill, 4, 0);
lv_obj_set_style_pad_hor(pill, 4, 0);
lv_obj_set_style_pad_ver(pill, 0, 0);
lv_obj_remove_flag(pill, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* badgeLabel = lv_label_create(pill);
lv_label_set_text(badgeLabel, ifaceBadge);
lv_obj_set_style_text_color(badgeLabel, lv_color_white(), 0);
lv_obj_set_style_text_font(badgeLabel, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_center(badgeLabel);
}
void TestViewBase::onBackClicked(lv_event_t* e) {
auto* self = static_cast<TestViewBase*>(lv_event_get_user_data(e));
if (!self || !self->app_) return;
self->onStop();
M5UnitTest* app = self->app_;
app->clearActiveTestView();
delete self;
app->showList();
}
@@ -0,0 +1,34 @@
#pragma once
#include <tt_app.h>
#include <lvgl.h>
class M5UnitTest;
// Minimal interface shared by all test views.
// To return to the list, concrete views call showList() on the owning M5UnitTest.
// M5UnitTest calls onStop() then deletes the view after the Back button is tapped.
class TestViewBase {
public:
virtual ~TestViewBase() = default;
virtual void onStart(lv_obj_t* parent, AppHandle handle, M5UnitTest* app) = 0;
virtual void onStop() = 0;
protected:
M5UnitTest* app_ = nullptr;
// Accent colors matching M5Stack product image palette
static constexpr lv_color_t COLOR_I2C = LV_COLOR_MAKE(0x1A, 0x6E, 0xC8); // M5 blue
static constexpr lv_color_t COLOR_GPIO = LV_COLOR_MAKE(0xC0, 0x20, 0x20); // red
static constexpr lv_color_t COLOR_UART = LV_COLOR_MAKE(0x20, 0x90, 0x50); // green
// Creates a standard toolbar with a Back button that returns to the list.
lv_obj_t* createToolbar(lv_obj_t* parent, AppHandle handle, const char* title);
// Creates a colored identity banner strip below the toolbar.
// ifaceBadge: short string e.g. "I2C", "GPIO", "UART"
void createBanner(lv_obj_t* parent, const char* unitName,
const char* ifaceBadge, lv_color_t accentColor);
static void onBackClicked(lv_event_t* e);
};
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <lvgl.h>
#include <tactility/lvgl_fonts.h>
// Device screen widths in default (portrait) orientation:
// tiny < 200 : small OLEDs, custom breadboard devices
// medium 200-399: M5Stack Core2, Cardputer (~240w)
// large 400-539: 800x480 devices (~480w portrait)
// large 540-719: M5PaperS3 (540w), 1024x600 (600w portrait)
// xlarge 720+ : Tab5 (720w portrait, 1280w landscape)
// uiW() reflects the actual rendered orientation (landscape flips w/h).
static constexpr lv_coord_t UI_TINY_THRESHOLD = 200;
static constexpr lv_coord_t UI_MEDIUM_THRESHOLD = 400;
static constexpr lv_coord_t UI_LARGE_THRESHOLD = 540;
inline lv_coord_t uiW() { return lv_display_get_horizontal_resolution(nullptr); }
inline lv_coord_t uiH() { return lv_display_get_vertical_resolution(nullptr); }
inline lv_coord_t uiShortSide() { lv_coord_t w = uiW(), h = uiH(); return w < h ? w : h; }
inline int uiPad() {
lv_coord_t w = uiW();
if (w < UI_TINY_THRESHOLD) return 4;
if (w < UI_MEDIUM_THRESHOLD) return 8;
if (w < UI_LARGE_THRESHOLD) return 12;
return 16;
}
inline int uiRowGap() {
lv_coord_t w = uiW();
if (w < UI_TINY_THRESHOLD) return 3;
if (w < UI_MEDIUM_THRESHOLD) return 6;
if (w < UI_LARGE_THRESHOLD) return 8;
return 12;
}
inline int uiCols() { return uiW() >= UI_TINY_THRESHOLD ? 2 : 1; }
inline enum LvglFontSize uiFont() {
lv_coord_t w = uiW();
return w < UI_TINY_THRESHOLD ? FONT_SIZE_SMALL : (w < UI_LARGE_THRESHOLD ? FONT_SIZE_DEFAULT : FONT_SIZE_LARGE);
}
+11
View File
@@ -0,0 +1,11 @@
#include "M5UnitTest.h"
#include <TactilityCpp/App.h>
extern "C" {
int main(int argc, char* argv[]) {
registerApp<M5UnitTest>();
return 0;
}
}
+10
View File
@@ -0,0 +1,10 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.m5unittest
versionName=0.1.0
versionCode=1
name=M5 Unit Test
+26 -9
View File
@@ -36,7 +36,7 @@ static const char* responses[] = {
static const char* getInputHint() {
if (tt_lvgl_hardware_keyboard_is_available()) {
return "Touch or press Space to ask";
return "Touch or Space to ask Q to exit";
}
return "Touch the ball to ask";
}
@@ -67,8 +67,21 @@ void Magic8Ball::onBallClick(lv_event_t* e) {
void Magic8Ball::onKey(lv_event_t* e) {
auto* self = (Magic8Ball*)lv_event_get_user_data(e);
uint32_t key = lv_event_get_key(e);
if (key == LV_KEY_ENTER || key == ' ') {
self->revealAnswer();
switch (key) {
case LV_KEY_ENTER:
case ' ':
self->revealAnswer();
break;
case LV_KEY_ESC:
case 'q':
case 'Q': {
lv_group_t* grp = lv_group_get_default();
if (grp) lv_group_set_editing(grp, false);
lv_group_remove_obj(lv_event_get_current_target_obj(e));
break;
}
default:
break;
}
}
@@ -127,15 +140,19 @@ void Magic8Ball::onShow(AppHandle app, lv_obj_t* parent) {
lv_obj_add_flag(ballObj, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(ballObj, onBallClick, LV_EVENT_CLICKED, this);
/* Keyboard support */
lv_group_t* grp = lv_group_get_default();
if (grp) lv_group_add_obj(grp, ballObj);
lv_obj_add_event_cb(ballObj, onKey, LV_EVENT_KEY, this);
/* Keyboard support - no editing mode needed, just focus the ball */
if (tt_lvgl_hardware_keyboard_is_available()) {
lv_group_t* grp = lv_group_get_default();
if (grp) {
lv_group_add_obj(grp, ballObj);
lv_group_focus_obj(ballObj);
}
lv_obj_add_event_cb(ballObj, onKey, LV_EVENT_KEY, this);
}
}
void Magic8Ball::onHide(AppHandle app) {
lv_group_t* grp = lv_group_get_default();
if (grp && ballObj) {
if (tt_lvgl_hardware_keyboard_is_available() && ballObj) {
lv_group_remove_obj(ballObj);
}
answerLabel = nullptr;
+2 -2
View File
@@ -5,6 +5,6 @@ sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.magic8ball
versionName=0.1.0
versionCode=1
versionName=0.2.0
versionCode=2
name=Magic 8-Ball
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(MediaKeys)
tactility_project(MediaKeys)
+11
View File
@@ -0,0 +1,11 @@
file(GLOB_RECURSE SOURCE_FILES
Source/*.c*
)
idf_component_register(
SRCS ${SOURCE_FILES}
# Library headers must be included directly,
# because all regular dependencies get stripped by elf_loader's cmake script
INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include
REQUIRES TactilitySDK
)
+347
View File
@@ -0,0 +1,347 @@
#include "MediaKeys.h"
#include <tactility/log.h>
#include <esp_heap_caps.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <tactility/lvgl_fonts.h>
#include <tt_lvgl.h>
#include <tt_lvgl_toolbar.h>
static const char* TAG = "MediaKeys";
// Button layout: two rows of three media-control buttons
static const char* BTN_MAP[] = {
LV_SYMBOL_PREV, LV_SYMBOL_PLAY, LV_SYMBOL_NEXT, "\n",
LV_SYMBOL_MUTE, LV_SYMBOL_VOLUME_MID, LV_SYMBOL_VOLUME_MAX, ""
};
// Physical key → button matrix index mapping (B=prev, P=play, N=next, M=mute, D=vol-, U=vol+)
static const struct { uint32_t key; uint32_t btnIdx; } KEY_MAP[] = {
{ 'b', 0 }, { 'B', 0 },
{ 'p', 1 }, { 'P', 1 },
{ 'n', 2 }, { 'N', 2 },
{ 'm', 3 }, { 'M', 3 },
{ 'd', 4 }, { 'D', 4 },
{ 'u', 5 }, { 'U', 5 },
};
// HID Consumer Page (0x0C) usage codes for each button, in BTN_MAP order
static const uint16_t CONSUMER_USAGE[6] = {
0x00B6, // 0 PREV: Previous Track
0x00CD, // 1 PLAY: Play/Pause
0x00B5, // 2 NEXT: Next Track
0x00E2, // 3 MUTE: Mute
0x00EA, // 4 VOL_DOWN: Volume Decrement
0x00E9, // 5 VOL_UP: Volume Increment
};
// Data passed to the one-shot key-send task
struct SendKeyData {
struct Device* hidDevice;
uint16_t usage;
};
// ---- Static task / callback implementations ----
void MediaKeys::sendKeyTask(void* param) {
SendKeyData* data = static_cast<SendKeyData*>(param);
uint8_t press[2] = {
static_cast<uint8_t>(data->usage & 0xFF),
static_cast<uint8_t>((data->usage >> 8) & 0xFF)
};
bluetooth_hid_device_send_consumer(data->hidDevice, press, 2);
vTaskDelay(pdMS_TO_TICKS(50));
uint8_t release[2] = {0, 0};
bluetooth_hid_device_send_consumer(data->hidDevice, release, 2);
delete data;
vTaskDelete(nullptr);
}
void MediaKeys::onSwitchToggled(lv_event_t* event) {
if (lv_event_get_code(event) != LV_EVENT_VALUE_CHANGED) return;
MediaKeys* self = static_cast<MediaKeys*>(lv_event_get_user_data(event));
if (!self) return;
bool enabled = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED);
self->handleSwitchToggle(enabled);
}
void MediaKeys::onButtonPressed(lv_event_t* event) {
if (lv_event_get_code(event) != LV_EVENT_VALUE_CHANGED) return;
MediaKeys* self = static_cast<MediaKeys*>(lv_event_get_user_data(event));
if (!self) return;
uint32_t id = lv_btnmatrix_get_selected_btn(lv_event_get_target_obj(event));
if (id != LV_BTNMATRIX_BTN_NONE) {
self->handleButtonPress(id);
}
}
void MediaKeys::onKeyEvent(lv_event_t* event) {
if (lv_event_get_code(event) != LV_EVENT_KEY) return;
MediaKeys* self = static_cast<MediaKeys*>(lv_event_get_user_data(event));
if (!self || !self->_buttonMatrix) return;
uint32_t key = lv_event_get_key(event);
// Q or Esc exits key mode and returns focus to normal UI navigation
if (key == 'q' || key == 'Q' || key == LV_KEY_ESC) {
self->exitKeyMode();
return;
}
if (!self->_isEnabled) return;
for (auto& mapping : KEY_MAP) {
if (mapping.key == key) {
// Highlight: select the button and mark checked for visual feedback
self->_activeKeyBtn = mapping.btnIdx;
lv_buttonmatrix_set_selected_button(self->_buttonMatrix, mapping.btnIdx);
lv_buttonmatrix_set_button_ctrl(self->_buttonMatrix, mapping.btnIdx, LV_BTNMATRIX_CTRL_CHECKED);
// Restart the highlight-clear timer
if (self->_keyHighlightTimer) {
lv_timer_reset(self->_keyHighlightTimer);
lv_timer_resume(self->_keyHighlightTimer);
}
self->handleButtonPress(mapping.btnIdx);
return;
}
}
}
void MediaKeys::enterKeyMode() {
if (_keyboardActive || !_buttonMatrix) return;
lv_group_t* group = lv_group_get_default();
if (!group) return;
lv_group_add_obj(group, _buttonMatrix);
lv_group_focus_obj(_buttonMatrix);
lv_group_set_editing(group, true);
_keyboardActive = true;
LOG_I(TAG, "Key mode: ON (Q/Esc to exit)");
}
void MediaKeys::exitKeyMode() {
if (!_keyboardActive || !_buttonMatrix) return;
lv_group_t* group = lv_group_get_default();
if (group) lv_group_set_editing(group, false);
lv_group_remove_obj(_buttonMatrix);
_keyboardActive = false;
LOG_I(TAG, "Key mode: OFF");
}
void MediaKeys::onKeyHighlightTimer(lv_timer_t* t) {
MediaKeys* self = static_cast<MediaKeys*>(lv_timer_get_user_data(t));
if (!self || !self->_buttonMatrix) return;
if (self->_activeKeyBtn != LV_BTNMATRIX_BTN_NONE) {
lv_buttonmatrix_clear_button_ctrl(self->_buttonMatrix, self->_activeKeyBtn, LV_BTNMATRIX_CTRL_CHECKED);
self->_activeKeyBtn = LV_BTNMATRIX_BTN_NONE;
}
lv_obj_remove_state(self->_buttonMatrix, LV_STATE_FOCUSED);
lv_timer_pause(t);
}
void MediaKeys::btEventCallback(struct Device* /*device*/, void* context, struct BtEvent event) {
MediaKeys* self = static_cast<MediaKeys*>(context);
if (!self) return;
if (event.type == BT_EVENT_RADIO_STATE_CHANGED) {
LOG_I(TAG, "BT radio state: %d", (int)event.radio_state);
if (event.radio_state == BT_RADIO_STATE_ON) {
// Radio is now up - start HID (needs LVGL lock for UI update)
if (tt_lvgl_lock(1000)) {
// Re-check inside lock to avoid TOCTOU race with handleSwitchToggle(false)
if (self->_radioEnabling) {
self->startHid();
}
tt_lvgl_unlock();
}
} else if (event.radio_state == BT_RADIO_STATE_OFF && self->_isEnabled) {
// Radio dropped while we were active - revert UI
LOG_I(TAG, "BT radio turned off, disabling HID");
if (tt_lvgl_lock(1000)) {
if (tt_lvgl_hardware_keyboard_is_available()) self->exitKeyMode();
self->_hidDevice = nullptr;
self->_isEnabled = false;
self->_radioEnabling = false;
if (self->_switchWidget) lv_obj_remove_state(self->_switchWidget, LV_STATE_CHECKED);
if (self->_mainWrapper) lv_obj_add_flag(self->_mainWrapper, LV_OBJ_FLAG_HIDDEN);
tt_lvgl_unlock();
}
}
} else if (event.type == BT_EVENT_PROFILE_STATE_CHANGED && event.profile_state.profile == BT_PROFILE_HID_DEVICE) {
LOG_I(TAG, "HID device: %s", event.profile_state.state == BT_PROFILE_STATE_CONNECTED ? "connected" : "disconnected");
}
}
// ---- Instance methods ----
void MediaKeys::startHid() {
// Called once the BT radio is confirmed ON (either already was, or just came up).
// May be called from the BT event callback thread - LVGL must already be locked by caller.
_radioEnabling = false;
_hidDevice = bluetooth_hid_device_get_device();
if (!_hidDevice) {
LOG_E(TAG, "BLE HID device unavailable after radio on");
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
_btDevice = nullptr;
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
error_t err = bluetooth_hid_device_start(_hidDevice, BT_HID_DEVICE_MODE_KEYBOARD);
if (err != ERROR_NONE) {
LOG_E(TAG, "Failed to start HID device: %d", (int)err);
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
_btDevice = nullptr;
_hidDevice = nullptr;
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
if (_mainWrapper) lv_obj_remove_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode();
}
void MediaKeys::handleSwitchToggle(bool enabled) {
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
_isEnabled = enabled;
if (enabled) {
_btDevice = bluetooth_find_first_ready_device();
if (!_btDevice) {
LOG_E(TAG, "No Bluetooth device found");
_isEnabled = false;
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
return;
}
bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
// Register callback before enabling radio so we don't miss the state-change event.
bluetooth_add_event_callback(_btDevice, this, btEventCallback);
enum BtRadioState radioState;
bluetooth_get_radio_state(_btDevice, &radioState);
if (radioState == BT_RADIO_STATE_ON) {
// Radio already up - start HID immediately.
_radioWasOff = false;
startHid();
} else {
// Turn the radio on; startHid() will be called from btEventCallback
// once BT_RADIO_STATE_ON fires.
LOG_I(TAG, "BT radio not on (state=%d), enabling...", (int)radioState);
_radioWasOff = true;
_radioEnabling = true;
bluetooth_set_radio_enabled(_btDevice, true);
}
} else {
_radioEnabling = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
_radioWasOff = false;
_btDevice = nullptr;
_hidDevice = nullptr;
if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
}
}
void MediaKeys::handleButtonPress(uint32_t buttonId) {
if (!_hidDevice || !_isEnabled || buttonId >= 6) return;
if (!bluetooth_hid_device_is_connected(_hidDevice)) {
LOG_W(TAG, "Not connected, ignoring button %lu", buttonId);
return;
}
LOG_I(TAG, "Button %lu pressed", buttonId);
SendKeyData* data = new SendKeyData {_hidDevice, CONSUMER_USAGE[buttonId]};
if (xTaskCreate(sendKeyTask, "bt_key", 4096, data, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) {
LOG_E(TAG, "Failed to create send task");
delete data;
}
}
void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) {
_parent = parent;
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
_switchWidget = tt_lvgl_toolbar_add_switch_action(toolbar);
lv_obj_add_event_cb(_switchWidget, onSwitchToggled, LV_EVENT_VALUE_CHANGED, this);
_mainWrapper = lv_obj_create(parent);
lv_obj_set_style_bg_color(_mainWrapper, lv_palette_darken(LV_PALETTE_GREY, 4), LV_PART_MAIN);
lv_obj_set_width(_mainWrapper, LV_PCT(100));
lv_obj_set_flex_grow(_mainWrapper, 1);
lv_obj_set_style_pad_all(_mainWrapper, 4, LV_PART_MAIN);
lv_obj_set_style_border_width(_mainWrapper, 0, LV_PART_MAIN);
lv_obj_remove_flag(_mainWrapper, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(_mainWrapper, LV_FLEX_FLOW_COLUMN);
_buttonMatrix = lv_btnmatrix_create(_mainWrapper);
lv_btnmatrix_set_map(_buttonMatrix, BTN_MAP);
lv_obj_set_style_text_font(_buttonMatrix, lvgl_get_text_font(FONT_SIZE_LARGE), LV_PART_ITEMS);
lv_obj_set_style_pad_all(_buttonMatrix, 5, LV_PART_MAIN);
lv_obj_set_style_pad_row(_buttonMatrix, 5, LV_PART_MAIN);
lv_obj_set_style_pad_column(_buttonMatrix, 5, LV_PART_MAIN);
lv_obj_set_style_border_width(_buttonMatrix, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(_buttonMatrix, 0, LV_PART_MAIN);
if (lv_display_get_horizontal_resolution(nullptr) <= 240 ||
lv_display_get_vertical_resolution(nullptr) <= 240) {
lv_obj_set_size(_buttonMatrix, lv_pct(100), lv_pct(70));
} else {
lv_obj_set_size(_buttonMatrix, lv_pct(100), lv_pct(85));
}
lv_obj_set_flex_grow(_buttonMatrix, 1);
lv_obj_add_event_cb(_buttonMatrix, onButtonPressed, LV_EVENT_VALUE_CHANGED, this);
// Physical keyboard support: key events on the matrix (entered when BT enabled, Q/Esc exits)
if (tt_lvgl_hardware_keyboard_is_available()) {
lv_obj_add_event_cb(_buttonMatrix, onKeyEvent, LV_EVENT_KEY, this);
_keyHighlightTimer = lv_timer_create(onKeyHighlightTimer, 150, this);
lv_timer_pause(_keyHighlightTimer);
}
lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
}
void MediaKeys::onHide(AppHandle /*appHandle*/) {
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
_btDevice = nullptr;
_hidDevice = nullptr;
_isEnabled = false;
_radioEnabling = false;
_radioWasOff = false;
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
if (_keyHighlightTimer) {
lv_timer_delete(_keyHighlightTimer);
_keyHighlightTimer = nullptr;
}
_activeKeyBtn = LV_BTNMATRIX_BTN_NONE;
_parent = nullptr;
_mainWrapper = nullptr;
_switchWidget = nullptr;
_buttonMatrix = nullptr;
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <TactilityCpp/App.h>
#include <lvgl.h>
#include <tactility/drivers/bluetooth.h>
#include <tactility/drivers/bluetooth_hid_device.h>
#include <tt_app.h>
#include <tt_lvgl_keyboard.h>
#include <atomic>
class MediaKeys final : public App {
// UI elements
lv_obj_t* _parent = nullptr;
lv_obj_t* _mainWrapper = nullptr;
lv_obj_t* _switchWidget = nullptr;
lv_obj_t* _buttonMatrix = nullptr;
lv_timer_t* _keyHighlightTimer = nullptr;
uint32_t _activeKeyBtn = LV_BTNMATRIX_BTN_NONE;
bool _keyboardActive = false; // true when matrix has focus group + editing mode
// HAL device handles
struct Device* _btDevice = nullptr;
struct Device* _hidDevice = nullptr;
// State - accessed from both LVGL thread and BT callback thread
std::atomic<bool> _isEnabled {false};
std::atomic<bool> _radioEnabling{false}; // true while waiting for radio to come ON
std::atomic<bool> _radioWasOff {false}; // true if MediaKeys turned the radio on (so we turn it off)
// Static event callbacks
static void onSwitchToggled(lv_event_t* e);
static void onButtonPressed(lv_event_t* e);
static void onKeyEvent(lv_event_t* e);
static void onKeyHighlightTimer(lv_timer_t* t);
static void btEventCallback(struct Device* device, void* context, struct BtEvent event);
static void sendKeyTask(void* param);
// Instance methods called by static callbacks
void handleSwitchToggle(bool enabled);
void handleButtonPress(uint32_t buttonId);
void startHid(); // called once radio is confirmed ON
void enterKeyMode();
void exitKeyMode();
public:
MediaKeys() = default;
MediaKeys(const MediaKeys&) = delete;
MediaKeys& operator=(const MediaKeys&) = delete;
MediaKeys(MediaKeys&&) = delete;
MediaKeys& operator=(MediaKeys&&) = delete;
void onShow(AppHandle appHandle, lv_obj_t* parent) override;
void onHide(AppHandle appHandle) override;
};
+11
View File
@@ -0,0 +1,11 @@
#include "MediaKeys.h"
#include <TactilityCpp/App.h>
extern "C" {
int main(int argc, char* argv[]) {
registerApp<MediaKeys>();
return 0;
}
}
+11
View File
@@ -0,0 +1,11 @@
[manifest]
version=0.1
[target]
sdk=0.7.0-dev
platforms=esp32s3,esp32p4
[app]
id=one.tactility.mediakeys
versionName=0.1.0
versionCode=1
name=Media Keys
description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
+34 -31
View File
@@ -6,21 +6,20 @@
#include <vector>
#include <lvgl.h>
#include <functional>
#include <memory>
#include <tt_app_alertdialog.h>
#include <tt_hal_uart.h>
#include <tt_lvgl.h>
#include <TactilityCpp/LvglLock.h>
#include <TactilityCpp/Uart.h>
#include <TactilityCpp/Preferences.h>
#include <tactility/device.h>
#include <tactility/drivers/uart_controller.h>
class ConnectView final : public View {
public:
typedef std::function<void(std::unique_ptr<Uart>)> OnConnectedFunction;
std::vector<std::string> uartNames;
typedef std::function<void(Device*)> OnConnectedFunction;
std::vector<Device*> uartDevices;
Preferences preferences = Preferences("SerialConsole");
LvglLock lvglLock;
@@ -30,13 +29,11 @@ private:
lv_obj_t* busDropdown = nullptr;
lv_obj_t* speedTextarea = nullptr;
std::string join(const std::vector<std::string>& list) {
std::string buildDeviceOptions() {
std::string output;
for (int i = list.size() - 1; i >= 0; i--) {
output.append(list[i].c_str());
if (i < list.size() - 1) {
output.append(",");
}
for (size_t i = 0; i < uartDevices.size(); i++) {
if (i > 0) output.append("\n");
output.append(uartDevices[i]->name);
}
return output;
}
@@ -54,36 +51,37 @@ private:
const char* alert_dialog_labels[] = { "OK" };
auto selected_uart_index = lv_dropdown_get_selected(busDropdown);
if (selected_uart_index >= uartNames.size()) {
uint32_t selected_index = lv_dropdown_get_selected(busDropdown);
if (selected_index >= uartDevices.size()) {
tt_app_alertdialog_start("Error", "No UART selected", alert_dialog_labels, 1);
return;
}
auto uart = Uart::open(selected_uart_index);
if (uart == nullptr) {
tt_app_alertdialog_start("Error", "Failed to connect to UART", alert_dialog_labels, 1);
return;
}
int speed = getSpeedInput();
if (speed <= 0) {
tt_app_alertdialog_start("Error", "Invalid speed", alert_dialog_labels, 1);
return;
}
if (!uart->start()) {
tt_app_alertdialog_start("Error", "Failed to initialize", alert_dialog_labels, 1);
return;
}
Device* dev = uartDevices[selected_index];
if (!uart->setBaudRate(speed)) {
uart->stop();
UartConfig cfg = {
(uint32_t)speed,
UART_CONTROLLER_DATA_8_BITS,
UART_CONTROLLER_PARITY_DISABLE,
UART_CONTROLLER_STOP_BITS_1,
};
if (uart_controller_set_config(dev, &cfg) != ERROR_NONE) {
tt_app_alertdialog_start("Error", "Failed to set baud rate", alert_dialog_labels, 1);
return;
}
onConnected(std::move(uart));
if (uart_controller_open(dev) != ERROR_NONE) {
tt_app_alertdialog_start("Error", "Failed to open UART", alert_dialog_labels, 1);
return;
}
onConnected(dev);
}
static void onConnectCallback(lv_event_t* event) {
@@ -104,7 +102,12 @@ public:
explicit ConnectView(OnConnectedFunction onConnected) : onConnected(std::move(onConnected)) {}
void onStart(lv_obj_t* parent) {
uartNames = Uart::getNames();
// Enumerate UART controller devices
uartDevices.clear();
device_for_each_of_type(&UART_CONTROLLER_TYPE, &uartDevices, [](Device* d, void* ctx) -> bool {
static_cast<std::vector<Device*>*>(ctx)->push_back(d);
return true;
});
auto* wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
@@ -118,15 +121,15 @@ public:
busDropdown = lv_dropdown_create(bus_wrapper);
auto bus_options = join(uartNames);
lv_dropdown_set_options(busDropdown, bus_options.c_str());
auto bus_options = buildDeviceOptions();
lv_dropdown_set_options(busDropdown, bus_options.empty() ? "none" : bus_options.c_str());
lv_obj_align(busDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_width(busDropdown, LV_PCT(50));
int32_t bus_index = 0;
preferences.optInt32("bus", bus_index);
if (bus_index < uartNames.size()) {
lv_dropdown_set_selected(busDropdown, bus_index);
if (bus_index >= 0 && (size_t)bus_index < uartDevices.size()) {
lv_dropdown_set_selected(busDropdown, (uint32_t)bus_index);
}
auto* bus_label = lv_label_create(bus_wrapper);
+26 -19
View File
@@ -6,13 +6,14 @@
#include <string>
#include <sstream>
#include <lvgl.h>
#include <memory>
#include <tt_lvgl.h>
#include <Tactility/RecursiveMutex.h>
#include <Tactility/Thread.h>
#include <TactilityCpp/LvglLock.h>
#include <tactility/device.h>
#include <tactility/drivers/uart_controller.h>
constexpr size_t receiveBufferSize = 512;
constexpr size_t renderBufferSize = receiveBufferSize + 2; // Leave space for newline at split and null terminator at the end
@@ -24,7 +25,7 @@ class ConsoleView final : public View {
lv_obj_t* _Nullable parent = nullptr;
lv_obj_t* _Nullable logTextarea = nullptr;
lv_obj_t* _Nullable inputTextarea = nullptr;
std::shared_ptr<Uart> _Nullable uart = nullptr;
Device* uartDev = nullptr;
std::unique_ptr<tt::Thread> uartThread _Nullable = nullptr;
bool uartThreadInterrupted = false;
std::unique_ptr<tt::Thread> viewThread _Nullable = nullptr;
@@ -91,18 +92,16 @@ class ConsoleView final : public View {
}
int32_t uartThreadMain() {
char byte;
while (!isUartThreadInterrupted()) {
assert(uart != nullptr);
bool success = uart->readByte(&byte, tt::kernel::millisToTicks(50));
uint8_t byte;
error_t err = uart_controller_read_byte(uartDev, &byte, tt::kernel::millisToTicks(50));
// Thread might've been interrupted in the meanwhile
if (isUartThreadInterrupted()) {
break;
}
if (success) {
if (err == ERROR_NONE) {
mutex.lock();
receiveBuffer[receiveBufferPosition++] = byte;
if (receiveBufferPosition == receiveBufferSize) {
@@ -110,7 +109,6 @@ class ConsoleView final : public View {
}
mutex.unlock();
}
}
return 0;
@@ -145,10 +143,17 @@ class ConsoleView final : public View {
std::string input_text = lv_textarea_get_text(inputTextarea);
std::string to_send;
to_send.append(input_text + terminatorString);
Device* localUart = uartDev;
mutex.unlock();
if (uart != nullptr) {
if (!uart->writeBytes(to_send.c_str(), to_send.length(), 100 / portTICK_PERIOD_MS)) {
if (localUart != nullptr) {
error_t err = uart_controller_write_bytes(
localUart,
reinterpret_cast<const uint8_t*>(to_send.c_str()),
to_send.length(),
tt::kernel::millisToTicks(100)
);
if (err != ERROR_NONE) {
ESP_LOGE(TAG, "Failed to send \"%s\"", input_text.c_str());
}
}
@@ -158,13 +163,16 @@ class ConsoleView final : public View {
public:
void startLogic(std::unique_ptr<Uart> newUart) {
void startLogic(Device* dev) {
assert(dev != nullptr);
if (dev == nullptr) return;
memset(receiveBuffer, 0, receiveBufferSize);
assert(uartThread == nullptr);
assert(uart == nullptr);
assert(uartDev == nullptr);
uart = std::move(newUart);
uartDev = dev;
uartThreadInterrupted = false;
uartThread = std::make_unique<tt::Thread>(
@@ -206,7 +214,6 @@ public:
lv_obj_set_width(terminator_dropdown, 70);
lv_obj_add_event_cb(terminator_dropdown, onTerminatorDropdownValueChangedCallback, LV_EVENT_VALUE_CHANGED, this);
auto* button = lv_button_create(input_wrapper);
auto* button_label = lv_label_create(button);
lv_label_set_text(button_label, "Send");
@@ -261,17 +268,17 @@ public:
auto lock = mutex.asScopedLock();
lock.lock();
if (uart != nullptr && uart->isStarted()) {
uart->stop();
uart = nullptr;
if (uartDev != nullptr) {
uart_controller_close(uartDev);
uartDev = nullptr;
}
}
void onStart(lv_obj_t* parent, std::unique_ptr<Uart> newUart) {
void onStart(lv_obj_t* parent, Device* dev) {
auto lock = mutex.asScopedLock();
lock.lock();
startLogic(std::move(newUart));
startLogic(dev);
startViews(parent);
}
@@ -11,10 +11,14 @@ void SerialConsole::stopActiveView() {
}
}
void SerialConsole::showConsoleView(std::unique_ptr<Uart> uart) {
void SerialConsole::showConsoleView(Device* dev) {
if (dev == nullptr) {
ESP_LOGE(TAG, "showConsoleView: null device");
return;
}
stopActiveView();
activeView = &consoleView;
consoleView.onStart(wrapperWidget, std::move(uart));
consoleView.onStart(wrapperWidget, dev);
lv_obj_remove_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN);
}
@@ -9,14 +9,14 @@ class SerialConsole final : public App {
lv_obj_t* disconnectButton = nullptr;
lv_obj_t* wrapperWidget = nullptr;
ConnectView connectView = ConnectView([this](auto uart){
showConsoleView(std::move(uart));
ConnectView connectView = ConnectView([this](Device* dev){
showConsoleView(dev);
});
ConsoleView consoleView;
View* activeView = nullptr;
void stopActiveView();
void showConsoleView(std::unique_ptr<Uart> uart);
void showConsoleView(Device* dev);
void showConnectView();
void onDisconnect();
static void onDisconnectPressed(lv_event_t* event);
+2 -2
View File
@@ -5,6 +5,6 @@ sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.serialconsole
versionName=0.3.0
versionCode=3
versionName=0.4.0
versionCode=4
name=Serial Console
+16 -12
View File
@@ -13,6 +13,7 @@
#include <TactilityCpp/LvglLock.h>
#include <tactility/lvgl_module.h>
#include <tactility/lvgl_fonts.h>
constexpr auto* TAG = "Snake";
@@ -42,14 +43,19 @@ static constexpr int32_t SELECTION_HELL = 4;
// Hell uses same size as Hard but with wall collision enabled
static const uint16_t difficultySizes[DIFFICULTY_COUNT] = { SNAKE_CELL_LARGE, SNAKE_CELL_MEDIUM, SNAKE_CELL_SMALL, SNAKE_CELL_SMALL };
static int getToolbarHeight(UiDensity density) {
if (density == LVGL_UI_DENSITY_COMPACT) {
return 22;
static uint32_t getToolbarHeight(UiDensity uiDensity) {
if (uiDensity == LVGL_UI_DENSITY_COMPACT) {
return lvgl_get_text_font_height(FONT_SIZE_DEFAULT) * 1.4f;
} else {
return 40;
return lvgl_get_text_font_height(FONT_SIZE_LARGE) * 2.2f;
}
}
static uint32_t getActionIconPadding(UiDensity uiDensity) {
auto toolbar_height = getToolbarHeight(uiDensity);
return (uiDensity != LVGL_UI_DENSITY_COMPACT) ? (uint32_t)(toolbar_height * 0.2f) : 8;
}
static void loadHighScores() {
PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE);
if (prefs) {
@@ -194,23 +200,21 @@ void Snake::createGame(lv_obj_t* parent, uint16_t cell_size, bool wallCollision,
lv_obj_set_style_text_color(scoreLabel, lv_palette_main(LV_PALETTE_GREEN), LV_PART_MAIN);
lv_obj_add_event_cb(gameObject, snakeEventCb, LV_EVENT_VALUE_CHANGED, this);
auto ui_density = lvgl_get_ui_density();
auto toolbar_height = getToolbarHeight(ui_density);
auto icon_padding = getActionIconPadding(ui_density);
// Create new game button wrapper
newGameWrapper = lv_obj_create(tb);
lv_obj_set_width(newGameWrapper, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(newGameWrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_all(newGameWrapper, 2, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(newGameWrapper, icon_padding / 2, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(newGameWrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(newGameWrapper, 0, LV_STATE_DEFAULT);
// Create new game button
auto ui_density = lvgl_get_ui_density();
auto toolbar_height = getToolbarHeight(ui_density);
lv_obj_t* newGameBtn = lv_btn_create(newGameWrapper);
if (ui_density == LVGL_UI_DENSITY_COMPACT) {
lv_obj_set_size(newGameBtn, toolbar_height - 8, toolbar_height - 8);
} else {
lv_obj_set_size(newGameBtn, toolbar_height - 6, toolbar_height - 6);
}
lv_obj_set_size(newGameBtn, toolbar_height - icon_padding, toolbar_height - icon_padding);
lv_obj_set_style_pad_all(newGameBtn, 0, LV_STATE_DEFAULT);
lv_obj_align(newGameBtn, LV_ALIGN_CENTER, 0, 0);
lv_obj_add_event_cb(newGameBtn, newGameBtnEvent, LV_EVENT_CLICKED, this);
+23 -20
View File
@@ -13,7 +13,7 @@
static void game_play_event(lv_event_t* e);
static void snake_timer_cb(lv_timer_t* timer);
static void delete_event(lv_event_t* e);
static void focus_event(lv_event_t* e);
static void reenter_key_mode_event(lv_event_t* e);
static void snake_draw(snake_game_t* game);
static void snake_create_segment_objects(snake_game_t* game);
static void snake_delete_segment_objects(snake_game_t* game);
@@ -35,12 +35,11 @@ static void delete_event(lv_event_t* e) {
game->timer = NULL;
}
// Restore edit mode to false before cleanup
// Restore edit mode and remove from group before cleanup
if (tt_lvgl_hardware_keyboard_is_available()) {
lv_group_t* group = lv_group_get_default();
if (group) {
lv_group_set_editing(group, false);
}
if (group) lv_group_set_editing(group, false);
lv_group_remove_obj(game->container);
}
// Delete LVGL objects for snake segments
@@ -59,21 +58,17 @@ static void delete_event(lv_event_t* e) {
}
/**
* @brief Handle focus/defocus to manage edit mode for keyboard input
* @brief Re-enter key mode when the game area is clicked after Q/Esc exit
*/
static void focus_event(lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
static void reenter_key_mode_event(lv_event_t* e) {
lv_obj_t* container = lv_event_get_current_target_obj(e);
lv_group_t* group = lv_group_get_default();
if (!group) return;
if (code == LV_EVENT_FOCUSED) {
// Enable edit mode so arrow keys control the game
lv_group_set_editing(group, true);
} else if (code == LV_EVENT_DEFOCUSED) {
// Restore normal focus navigation
lv_group_set_editing(group, false);
if (lv_obj_get_group(container) == NULL) {
lv_group_add_obj(group, container);
}
lv_group_focus_obj(container);
lv_group_set_editing(group, true);
}
/**
@@ -278,6 +273,16 @@ static void game_play_event(lv_event_t* e) {
case '/':
snake_set_direction(game, SNAKE_DIR_RIGHT);
break;
case LV_KEY_ESC:
case 'q':
case 'Q': {
// Exit key/edit mode - remove from group so navigation is restored
// Re-entry: tap/click the game area to return to key mode
lv_group_t* grp = lv_group_get_default();
if (grp) lv_group_set_editing(grp, false);
lv_group_remove_obj(lv_event_get_current_target_obj(e));
break;
}
default:
break;
}
@@ -396,11 +401,9 @@ lv_obj_t* snake_create(lv_obj_t* parent, uint16_t cell_size, bool wall_collision
lv_group_t* group = lv_group_get_default();
if (group) {
lv_group_add_obj(group, game->container);
// Register focus handlers to manage edit mode lifecycle
lv_obj_add_event_cb(game->container, focus_event, LV_EVENT_FOCUSED, NULL);
lv_obj_add_event_cb(game->container, focus_event, LV_EVENT_DEFOCUSED, NULL);
// Focus the container (will trigger FOCUSED event and enable edit mode)
lv_obj_add_event_cb(game->container, reenter_key_mode_event, LV_EVENT_CLICKED, NULL);
lv_group_focus_obj(game->container);
lv_group_set_editing(group, true);
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ sdk=0.7.0-dev
platforms=esp32,esp32s3,esp32c6,esp32p4
[app]
id=one.tactility.snake
versionName=0.4.0
versionCode=4
versionName=0.5.0
versionCode=5
name=Snake
description=Classic Snake game
+24 -14
View File
@@ -5,6 +5,7 @@
#include <tt_lvgl_toolbar.h>
#include <tt_lvgl_keyboard.h>
#include <tactility/lvgl_module.h>
#include <tactility/lvgl_fonts.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -32,12 +33,24 @@ static bool ensureDir() {
size_t size = sizeof(dir);
tt_app_get_user_data_path(s_appHandle, dir, &size);
if (size == 0) return false;
for (char* p = dir + 1; *p; ++p) {
if (*p == '/') { *p = '\0'; mkdir(dir, 0755); *p = '/'; }
}
mkdir(dir, 0755);
return true;
}
static int getToolbarHeight(UiDensity density) {
return (density == LVGL_UI_DENSITY_COMPACT) ? 22 : 40;
static uint32_t getToolbarHeight(UiDensity uiDensity) {
if (uiDensity == LVGL_UI_DENSITY_COMPACT) {
return lvgl_get_text_font_height(FONT_SIZE_DEFAULT) * 1.4f;
} else {
return lvgl_get_text_font_height(FONT_SIZE_LARGE) * 2.2f;
}
}
static uint32_t getActionIconPadding(UiDensity uiDensity) {
auto toolbar_height = getToolbarHeight(uiDensity);
return (uiDensity != LVGL_UI_DENSITY_COMPACT) ? (uint32_t)(toolbar_height * 0.2f) : 8;
}
/* ── Persistence ──────────────────────────────────────────────────── */
@@ -159,9 +172,9 @@ void TodoList::rebuildList() {
lv_obj_clean(list);
UiDensity uiDensity = lvgl_get_ui_density();
int toolbarHeight = getToolbarHeight(uiDensity);
int btnSize = (uiDensity == LVGL_UI_DENSITY_COMPACT) ? toolbarHeight - 8 : toolbarHeight - 6;
auto ui_density = lvgl_get_ui_density();
auto toolbar_height = getToolbarHeight(ui_density);
auto icon_padding = getActionIconPadding(ui_density);
if (count == 0) {
lv_list_add_text(list, "No tasks yet. Add one below!");
@@ -182,7 +195,7 @@ void TodoList::rebuildList() {
lv_obj_add_event_cb(btn, onItemClicked, LV_EVENT_SHORT_CLICKED, (void*)(intptr_t)i);
lv_obj_t* delBtn = lv_button_create(btn);
lv_obj_set_size(delBtn, btnSize, btnSize);
lv_obj_set_size(delBtn, toolbar_height - icon_padding, toolbar_height - icon_padding);
lv_obj_align(delBtn, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_style_bg_color(delBtn, lv_palette_main(LV_PALETTE_RED), LV_PART_MAIN);
lv_obj_set_style_pad_all(delBtn, 0, LV_PART_MAIN);
@@ -286,23 +299,20 @@ void TodoList::onShow(AppHandle app, lv_obj_t* parent) {
lv_obj_set_style_text_font(countLabel, lv_font_get_default(), 0);
lv_obj_set_style_text_color(countLabel, lv_palette_main(LV_PALETTE_CYAN), LV_PART_MAIN);
UiDensity uiDensity = lvgl_get_ui_density();
int toolbarHeight = getToolbarHeight(uiDensity);
auto ui_density = lvgl_get_ui_density();
auto toolbar_height = getToolbarHeight(ui_density);
auto icon_padding = getActionIconPadding(ui_density);
lv_obj_t* btnWrapper = lv_obj_create(toolbar);
lv_obj_set_width(btnWrapper, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(btnWrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_all(btnWrapper, 2, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(btnWrapper, icon_padding / 2, LV_STATE_DEFAULT);
lv_obj_set_style_pad_column(btnWrapper, 4, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(btnWrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(btnWrapper, 0, LV_STATE_DEFAULT);
int btnSize = (uiDensity == LVGL_UI_DENSITY_COMPACT)
? toolbarHeight - 8
: toolbarHeight - 6;
lv_obj_t* clearBtn = lv_button_create(btnWrapper);
lv_obj_set_size(clearBtn, btnSize, btnSize);
lv_obj_set_size(clearBtn, toolbar_height - icon_padding, toolbar_height - icon_padding);
lv_obj_set_style_pad_all(clearBtn, 0, LV_STATE_DEFAULT);
lv_obj_align(clearBtn, LV_ALIGN_CENTER, 0, 0);
lv_obj_add_event_cb(clearBtn, onClearDoneClicked, LV_EVENT_CLICKED, nullptr);

Some files were not shown because too many files have changed in this diff Show More