diff --git a/.gitignore b/.gitignore index 3e5f418..85651e7 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,12 @@ test_umbrella examples/hello/hello examples/hello/render_test*.png +# ---- 图库产物 ---- +tuku/tuku +tuku/*.mpk +tuku/build/ +tuku/test/test_library + # ---- 杂项 ---- *.orig *.log diff --git a/README.md b/README.md index 2460816..cc28f12 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ MeowOS-SDK/ │ ├── ui/ # 每页一个 view 文件(home/demo/list/full/chrome) │ ├── logic/ # 纯 C 业务逻辑(不依赖 meow_ui.h,宿主可裸测) │ └── build_hello.sh # 构建 hello(链 meow_ui.a + libz.a + -lpthread) +├── tuku/ # 图库:漫画书库 App(M3 骨架:主题同步/目录浏览/阅读器桩) +│ ├── app.c # main + 装配 + meow_ui_run 回调路由 + 主题监听 +│ ├── logic/ # 纯 C:目录扫描/自然排序、阅读方向状态(宿主可裸测) +│ ├── ui/ # library / reader / settings / lock 每页一个 view +│ ├── build_tuku.sh # 交叉构建(SDK 用 ${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}) +│ └── pack_tuku.sh # 出包 dev.tuku.app.mpk └── docs/ ├── APP_DEV_GUIDE.md # 第三方开发上手指南(环境 → 最小 App → 构建打包 → 签名安装 → 调试) └── MEOW_APP_SDK.md # SDK 设计全量规范 @@ -46,6 +52,11 @@ cd sdk # 2) 构建示例 App hello cd ../examples/hello ./build_hello.sh # 产出 examples/hello/hello(静态 MIPS32r2 ELF) + +# 3) 构建图库(SDK 用绝对路径,可用 MEOW_SDK_DIR 覆盖) +cd ../../tuku +./build_tuku.sh # 产出 tuku/tuku(静态 MIPS32r2 ELF) +./pack_tuku.sh # 产出 tuku/dev.tuku.app.mpk ``` ## SDK 能力速览 diff --git a/tuku/app.c b/tuku/app.c new file mode 100644 index 0000000..7f380ca --- /dev/null +++ b/tuku/app.c @@ -0,0 +1,137 @@ +/* app.c — 图库装配层:main + 建页 + meow_ui_run 回调路由 + 主题监听。 + * + * 契约(docs/APP_DEV_GUIDE.md §2-§3): + * - main 只做 meow_ui_init → 建 view → meow_ui_run → 清理;退出走 meow_ui_request_exit; + * - 页面切换统一走 meow_view_page_push/pop(activity 栈 + on_enter/on_leave 唯一入口); + * - 物理键不映射返回/确认(on_key 仅日志);屏底上滑由 SDK 系统手势接管,App 不拦截; + * - 左缘返回:书库内逐级上移,根目录才退出。 + * + * 主题同步:注册 meow_theme_add_listener;明暗/风格族切换时更新页面底色并整帧 invalidate, + * 各页 on_draw / SDK 控件下一帧即用新语义色重绘(不缓存颜色)。 + */ +#include "tuku_internal.h" +#include "meow_ui_nav.h" + +#include + +static void on_sigterm(int s) +{ + (void)s; + meow_ui_request_exit(); +} + +/* 向 launcher 上报当前页栈深/顶层,供系统导航栈跟随。各页 on_enter 调用。 */ +void mr_nav_report(void) +{ + int depth = meow_ui_activity_depth(); + if (depth <= 0) depth = 1; + int id = meow_ui_activity_active(); + const char *top = (id == ACT_READER) ? "reader" + : (id == ACT_SETTINGS) ? "settings" + : (id == ACT_LOCK) ? "lock" + : "library"; + meow_ui_nav_report((uint32_t)depth, top); +} + +/* 主题切换:页面底色 + 整帧重绘(控件与 on_draw 全部从 meow_theme_get() 取色)。 */ +static void app_theme_changed(meow_theme_mode m) +{ + (void)m; + meow_ui_set_page_bg(meow_theme_get()->bg); + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); +} + +static int app_active(void) +{ + return meow_ui_activity_active(); +} + +static void app_on_key(int keycode, int is_down) +{ + (void)keycode; (void)is_down; /* 物理键是系统语义,不做返回/确认映射 */ + meow_log(MEOW_LOG_DEBUG, "key"); +} + +static void app_on_tap(int x, int y) +{ + switch (app_active()) { + case ACT_LIBRARY: library_on_tap(x, y); break; + case ACT_READER: reader_on_tap(x, y); break; + default: break; + } +} + +static void app_on_swipe(int dir, int dx, int dy) +{ + (void)dy; + if (dir == MEOW_SWIPE_LEFT) { + if (app_active() == ACT_LIBRARY) { + if (!library_go_up()) { + meow_log(MEOW_LOG_INFO, "exit:swipe"); + meow_ui_request_exit(); + } + } else { + meow_view_page_pop(); + } + return; + } + /* SDK 的 on_swipe 把位移量放在第二参(第三参为 0):UP/DOWN 时 dx=纵向位移。 */ + if (app_active() == ACT_LIBRARY) library_on_swipe(dir, dx); +} + +static void app_on_tick(void) +{ + library_on_tick(); +} + +static void app_on_swipe_progress(int x_start, int dx) +{ + meow_ui_swipe_hint(x_start + dx); +} + +static void app_on_touch_down(int x, int y) +{ + if (app_active() == ACT_LIBRARY) library_on_touch_down(x, y); +} + +static void app_on_touch_move(int x, int y) +{ + if (app_active() == ACT_LIBRARY) library_on_touch_move(x, y); +} + +int main(void) +{ + meow_log(MEOW_LOG_INFO, "start"); + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { + meow_log(MEOW_LOG_ERROR, "init failed"); + return 1; + } + + meow_theme_add_listener(app_theme_changed); + meow_ui_set_page_bg(meow_theme_get()->bg); + meow_ui_nav_init(); + signal(SIGTERM, on_sigterm); + + /* 先建全部页面(on_create 建各自控件/扫描书库),再按启动锁状态进根页。 */ + meow_view *library = library_view_create(); + meow_view *reader = reader_view_create(); + meow_view *settings = settings_view_create(); + meow_view *lock = lock_view_create(); + + meow_view_page_push(lock_view_should_show() ? ACT_LOCK : ACT_LIBRARY); + + meow_ui_events ev = { + app_on_key, app_on_tap, app_on_swipe, app_on_tick, + app_on_swipe_progress, app_on_touch_down, app_on_touch_move, + }; + meow_ui_run(&ev); + + meow_view_free(lock); + meow_view_free(settings); + meow_view_free(reader); + meow_view_free(library); + meow_ui_shutdown(); + meow_log(MEOW_LOG_INFO, "exit"); + return 0; +} diff --git a/tuku/app.json b/tuku/app.json new file mode 100644 index 0000000..1b5005d --- /dev/null +++ b/tuku/app.json @@ -0,0 +1,3 @@ +{"name":"图库","exec":"/usr/data/mnt/sd_0/MeowAPPs/dev.tuku.app/tuku", + "package":"dev.tuku.app","version":"0.1","version_code":1,"framework":1, + "fonts":"common","route.reader.fullscreen":1} diff --git a/tuku/build_tuku.sh b/tuku/build_tuku.sh new file mode 100755 index 0000000..1509a3b --- /dev/null +++ b/tuku/build_tuku.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# build_tuku.sh — 构建图库(静态 MIPS32r2,链 meow_ui.a + libz.a + -lpthread)。 +# +# SDK 路径用绝对路径(默认 $HOME/Proj/MeowOS-SDK/sdk),可用 MEOW_SDK_DIR 覆盖; +# 故意不用 ../../sdk —— 交付时 tuku/ 会搬到独立仓 ~/Proj/Meow-OS-APPs/PicBrowser。 +# 交叉编译器/sysroot 同 SDK:MEOW_CC / MEOW_SYSROOT 可覆盖。 +set -e +cd "$(dirname "$0")" +CC="${MEOW_CC:-$HOME/.tools/zig-mipsel-cc}" +SYSROOT="${MEOW_SYSROOT:-$HOME/.tools/mipsel-linux-musleabi-sysroot}" +SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" + +"$CC" -Os -static -Wall -Wextra -I"$SDK" -I. \ + app.c ui/library/library.c ui/reader/reader.c ui/settings/settings.c ui/lock/lock.c \ + logic/library.c logic/reader.c \ + "$SDK/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o tuku +ls -la tuku +file tuku diff --git a/tuku/logic/library.c b/tuku/logic/library.c new file mode 100644 index 0000000..38a5d40 --- /dev/null +++ b/tuku/logic/library.c @@ -0,0 +1,247 @@ +/* logic/library.c — 书库目录扫描 + 自然排序(纯 C)。 + * + * 扫描分两步: + * 1) scan_info():递归探测一个目录是否「最终含图」,并带回直接图片数 / 含图子目录数 / + * 缩略图源(优先本目录自然序第一张,其次第一个含图子目录); + * 2) mr_library_scan():只对目标目录的**直接子目录**调用 scan_info(),把 nav=1 的目录 + * 收进结果并自然排序——因此根视图不会列出后代条目,进入子目录时再重新扫描该层。 + * + * 内存:条目数组倍增分配;路径/名称定长,随 mr_library_free() 释放。 + */ +#include "library.h" + +#include +#include +#include +#include +#include +#include +#include + +/* 系统目录不参与书库(SD 根上的 MeowOS 目录树)。 */ +static int ignored_dir_name(const char *name) +{ + static const char *const skip[] = { "MeowAPPs", "MeowData", "MeowOS", "lost+found" }; + if (!name || name[0] == '.') return 1; + for (unsigned i = 0; i < sizeof skip / sizeof skip[0]; i++) + if (strcmp(name, skip[i]) == 0) return 1; + return 0; +} + +static int copy_str(char *dst, int dstsz, const char *src) +{ + if (!dst || dstsz <= 0) return -1; + if (!src) { dst[0] = '\0'; return 0; } + int n = (int)strlen(src); + if (n >= dstsz) return -1; + memcpy(dst, src, (size_t)n + 1); + return 0; +} + +static int join_path(char *out, int outsz, const char *dir, const char *name) +{ + if (!out || outsz <= 0 || !dir || !name) return -1; + int n; + size_t dl = strlen(dir); + if (dl > 0 && dir[dl - 1] == '/') + n = snprintf(out, (size_t)outsz, "%s%s", dir, name); + else + n = snprintf(out, (size_t)outsz, "%s/%s", dir, name); + return (n > 0 && n < outsz) ? 0 : -1; +} + +int mr_path_is_image(const char *name) +{ + if (!name) return 0; + const char *dot = strrchr(name, '.'); + if (!dot || dot == name) return 0; + dot++; + return strcasecmp(dot, "png") == 0 || strcasecmp(dot, "jpg") == 0 || + strcasecmp(dot, "jpeg") == 0 || strcasecmp(dot, "bmp") == 0; +} + +int mr_natural_cmp(const char *a, const char *b) +{ + const unsigned char *pa = (const unsigned char *)a; + const unsigned char *pb = (const unsigned char *)b; + if (!a || !b) return (a == b) ? 0 : (a ? 1 : -1); + + while (*pa && *pb) { + if (isdigit(*pa) && isdigit(*pb)) { + const unsigned char *za = pa, *zb = pb; + while (*pa == '0') pa++; + while (*pb == '0') pb++; + const unsigned char *da = pa, *db = pb; + while (isdigit(*pa)) pa++; + while (isdigit(*pb)) pb++; + + size_t la = (size_t)(pa - da), lb = (size_t)(pb - db); + if (la != lb) return la < lb ? -1 : 1; + if (la > 0) { + int c = memcmp(da, db, la); + if (c != 0) return c < 0 ? -1 : 1; + } + size_t oa = (size_t)(da - za), ob = (size_t)(db - zb); + if (oa != ob) return oa < ob ? -1 : 1; + continue; + } + + int ca = tolower(*pa), cb = tolower(*pb); + if (ca != cb) return ca < cb ? -1 : 1; + pa++; + pb++; + } + if (*pa) return 1; + if (*pb) return -1; + return strcmp(a, b) < 0 ? -1 : (strcmp(a, b) > 0 ? 1 : 0); +} + +typedef struct scan_info { + int pages; /* 直接图片数 */ + int subdirs; /* 含图子目录数 */ + int nav; /* 1=本目录最终含图(可直接看或可导航到图) */ + char cover[MR_PATH_MAX]; /* 缩略图源 */ + char first_name[MR_NAME_MAX]; /* 本目录自然序第一张图片的文件名(比较用) */ +} scan_info; + +static void scan_dir(const char *path, int depth, int max_depth, scan_info *si) +{ + memset(si, 0, sizeof *si); + DIR *d = opendir(path); + if (!d) return; + + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *name = de->d_name; + if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; + if (ignored_dir_name(name)) continue; + + char full[MR_PATH_MAX]; + if (join_path(full, (int)sizeof full, path, name) != 0) continue; + + struct stat st; + if (lstat(full, &st) != 0) continue; + if (S_ISLNK(st.st_mode)) continue; + + if (S_ISDIR(st.st_mode)) { + if (depth >= max_depth) continue; + scan_info child; + scan_dir(full, depth + 1, max_depth, &child); + if (!child.nav) continue; + si->subdirs++; + if (!si->cover[0] && child.cover[0]) + copy_str(si->cover, (int)sizeof si->cover, child.cover); + } else if (S_ISREG(st.st_mode) && mr_path_is_image(name)) { + si->pages++; + if (!si->first_name[0] || mr_natural_cmp(name, si->first_name) < 0) { + copy_str(si->first_name, (int)sizeof si->first_name, name); + copy_str(si->cover, (int)sizeof si->cover, full); + } + } + } + closedir(d); + si->nav = (si->pages > 0 || si->subdirs > 0); +} + +void mr_library_free(mr_library *lib) +{ + if (!lib) return; + free(lib->items); + lib->items = NULL; + lib->count = 0; + lib->cap = 0; +} + +static int lib_add(mr_library *lib, const mr_library_entry *e) +{ + if (lib->count >= lib->cap) { + int cap = lib->cap ? lib->cap * 2 : 16; + mr_library_entry *ni = (mr_library_entry *)realloc(lib->items, + (size_t)cap * sizeof *ni); + if (!ni) return -1; + lib->items = ni; + lib->cap = cap; + } + lib->items[lib->count++] = *e; + return 0; +} + +static int entry_cmp(const void *pa, const void *pb) +{ + const mr_library_entry *a = (const mr_library_entry *)pa; + const mr_library_entry *b = (const mr_library_entry *)pb; + return mr_natural_cmp(a->name, b->name); +} + +int mr_library_scan(mr_library *lib, const char *dir, int max_depth) +{ + if (!lib) return -1; + mr_library_free(lib); + if (!dir || !dir[0]) return -1; + + if (max_depth <= 0 || max_depth > MR_LIB_MAX_DEPTH) max_depth = MR_LIB_MAX_DEPTH; + + DIR *d = opendir(dir); + if (!d) return -1; + + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *name = de->d_name; + if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; + if (ignored_dir_name(name)) continue; + + char full[MR_PATH_MAX]; + if (join_path(full, (int)sizeof full, dir, name) != 0) continue; + + struct stat st; + if (lstat(full, &st) != 0) continue; + if (S_ISLNK(st.st_mode) || !S_ISDIR(st.st_mode)) continue; + + scan_info si; + scan_dir(full, 1, max_depth, &si); + if (!si.nav) continue; + + mr_library_entry e; + memset(&e, 0, sizeof e); + if (copy_str(e.name, (int)sizeof e.name, name) != 0) continue; + if (copy_str(e.path, (int)sizeof e.path, full) != 0) continue; + copy_str(e.cover, (int)sizeof e.cover, si.cover); + e.pages = si.pages; + e.subdirs = si.subdirs; + e.has_images = (si.pages > 0); + if (e.has_images) + snprintf(e.subtitle, sizeof e.subtitle, "%d page%s", e.pages, + e.pages == 1 ? "" : "s"); + else + snprintf(e.subtitle, sizeof e.subtitle, "%d folder%s", e.subdirs, + e.subdirs == 1 ? "" : "s"); + if (lib_add(lib, &e) != 0) { + closedir(d); + mr_library_free(lib); + return -1; + } + } + closedir(d); + + if (lib->count > 1) qsort(lib->items, (size_t)lib->count, sizeof *lib->items, entry_cmp); + return lib->count; +} + +int mr_path_parent(const char *path, char *out, int outsz) +{ + if (!path || !out || outsz <= 0) return -1; + size_t n = strlen(path); + while (n > 1 && path[n - 1] == '/') n--; + if (n <= 1) { + if (outsz < 2) return -1; + out[0] = '/'; + out[1] = '\0'; + return 0; + } + while (n > 0 && path[n - 1] != '/') n--; + while (n > 1 && path[n - 1] == '/') n--; + if (n >= (size_t)outsz) return -1; + memcpy(out, path, n); + out[n] = '\0'; + return 0; +} diff --git a/tuku/logic/library.h b/tuku/logic/library.h new file mode 100644 index 0000000..e97d7a6 --- /dev/null +++ b/tuku/logic/library.h @@ -0,0 +1,63 @@ +/* logic/library.h — 漫画书库扫描/排序(纯 C,不依赖 meow_ui.h)。 + * + * 分层契约同 examples/hello:logic 层只含文件系统与纯数据,宿主 gcc 可直接裸测。 + * 书库语义: + * - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」; + * - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含图的中间目录(可继续导航); + * - 行的 pages=该目录直接图片数;subdirs=其下含图子目录数(中间目录用于副标题); + * - cover=缩略图源文件:优先该目录自然序第一张图,否则取第一个含图子目录的 cover; + * - 条目按名称自然排序(page2 < page10),含前导零与数字/字母混合名。 + * + * 深度上限 MR_LIB_MAX_DEPTH 用于防止坏卡/超大树的无限递归;隐藏目录与系统目录 + * (. 开头、MeowAPPs/MeowData/MeowOS/lost+found)不参与书库。符号链接跳过(防环)。 + */ +#ifndef MR_LIBRARY_H +#define MR_LIBRARY_H + +#ifdef __cplusplus +extern "C" { +#endif + +#define MR_PATH_MAX 512 +#define MR_NAME_MAX 256 +#define MR_LIB_MAX_DEPTH 8 + +typedef struct mr_library_entry { + char name[MR_NAME_MAX]; /* 目录名(行主标题) */ + char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader 用) */ + char cover[MR_PATH_MAX]; /* 缩略图源图(空=none) */ + char subtitle[48]; /* 预先格式化好的 "N pages" / "N folders" */ + int pages; /* 本目录直接图片数 */ + int subdirs; /* 本目录下含图子目录数 */ + int has_images; /* 1=本目录直接含图(点入进 reader) */ +} mr_library_entry; + +typedef struct mr_library { + mr_library_entry *items; + int count; + int cap; +} mr_library; + +/* 扩展名判断:png/jpg/jpeg/bmp(大小写不敏感)。1=受支持图片。 */ +int mr_path_is_image(const char *name); + +/* 自然排序比较:数字段按数值比(page2 < page10),数字相等时前导零少的在前; + * 非数字段 ASCII 大小写不敏感,完全等价时按原始字节序定序。 */ +int mr_natural_cmp(const char *a, const char *b); + +/* 扫描 dir 的直接子目录,写入 lib(先释放 lib 旧内容)。 + * 返回条目数(>=0);dir 不可读返回 -1(lib 仍为空且可安全 mr_library_free)。 + * max_depth<=0 或超过 MR_LIB_MAX_DEPTH 时按上限处理。 */ +int mr_library_scan(mr_library *lib, const char *dir, int max_depth); + +/* 释放条目数组并清零(可重复调用)。 */ +void mr_library_free(mr_library *lib); + +/* 取父目录路径(去掉末尾 '/' 后截断最后一段)。根 "/" 返回 "/"。 + * 成功 0;参数非法/输出缓冲不足 -1。 */ +int mr_path_parent(const char *path, char *out, int outsz); + +#ifdef __cplusplus +} +#endif +#endif /* MR_LIBRARY_H */ diff --git a/tuku/logic/reader.c b/tuku/logic/reader.c new file mode 100644 index 0000000..d84d5d0 --- /dev/null +++ b/tuku/logic/reader.c @@ -0,0 +1,14 @@ +/* logic/reader.c — 阅读方向状态(默认 RTL;M4 的阅读器直接复用)。 */ +#include "reader.h" + +static int g_direction = MR_DIR_RTL; + +int mr_reader_get_direction(void) +{ + return g_direction; +} + +void mr_reader_set_direction(int dir) +{ + if (dir == MR_DIR_RTL || dir == MR_DIR_LTR) g_direction = dir; +} diff --git a/tuku/logic/reader.h b/tuku/logic/reader.h new file mode 100644 index 0000000..5571cdd --- /dev/null +++ b/tuku/logic/reader.h @@ -0,0 +1,23 @@ +/* logic/reader.h — 阅读器纯逻辑接缝(M4 填充实现,M3 先落状态 API)。 + * + * 阅读方向默认 RTL(日式漫画从右往左翻);设置页/阅读器可切 LTR。 + * 本模块保持纯 C(不 include meow_ui.h),宿主可直接测。 + */ +#ifndef MR_READER_H +#define MR_READER_H + +#ifdef __cplusplus +extern "C" { +#endif + +#define MR_DIR_RTL 1 /* 默认:右到左(日漫) */ +#define MR_DIR_LTR 0 /* 左到右 */ + +int mr_reader_get_direction(void); +/* 只接受 MR_DIR_RTL / MR_DIR_LTR;其它值忽略(保持原方向)。 */ +void mr_reader_set_direction(int dir); + +#ifdef __cplusplus +} +#endif +#endif /* MR_READER_H */ diff --git a/tuku/pack_tuku.sh b/tuku/pack_tuku.sh new file mode 100755 index 0000000..b41b8dc --- /dev/null +++ b/tuku/pack_tuku.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# pack_tuku.sh — 出包 dev.tuku.app.mpk(tar.gz,根目录 dev.tuku.app/,含 app.json + tuku)。 +# .mpk 文件名茎 = app.json.package = 安装目录名(dev.tuku.app)。 +set -e +cd "$(dirname "$0")" +[ -x tuku ] || ./build_tuku.sh + +rm -rf build/dev.tuku.app +mkdir -p build/dev.tuku.app +cp app.json tuku build/dev.tuku.app/ + +rm -f dev.tuku.app.mpk +( cd build && tar czf ../dev.tuku.app.mpk dev.tuku.app ) +ls -la dev.tuku.app.mpk diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh new file mode 100755 index 0000000..26a31da --- /dev/null +++ b/tuku/test/run_tests.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# run_tests.sh — 宿主 gcc 构建并运行图库 logic 层测试(不链 SDK、不需要设备)。 +set -e +cd "$(dirname "$0")/.." +gcc -Wall -Wextra -O1 -I. \ + test/test_library.c logic/library.c logic/reader.c \ + -o test/test_library +./test/test_library diff --git a/tuku/test/test_library.c b/tuku/test/test_library.c new file mode 100644 index 0000000..65b0e2e --- /dev/null +++ b/tuku/test/test_library.c @@ -0,0 +1,223 @@ +/* test_library.c — 图库 logic 层宿主测试(纯 C,无 SDK/硬件)。 + * + * 覆盖: + * T1 自然排序:数字段数值序(page2 +#include +#include +#include +#include +#include + +static int g_pass, g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { g_pass++; } \ + else { g_fail++; printf("FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +static int mkdir_p(const char *path) +{ + char tmp[MR_PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s", path); + for (size_t i = 1; i <= strlen(tmp); i++) { + if (tmp[i] == '/' || tmp[i] == '\0') { + char ch = tmp[i]; + tmp[i] = '\0'; + if (mkdir(tmp, 0755) != 0) { + struct stat st; + if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1; + } + tmp[i] = ch; + } + } + return 0; +} + +static int touch_file(const char *path) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + fputs("x", f); + return fclose(f); +} + +static int rm_rf(const char *path) +{ + struct stat st; + if (lstat(path, &st) != 0) return -1; + if (!S_ISDIR(st.st_mode)) return unlink(path); + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; + char full[MR_PATH_MAX]; + snprintf(full, sizeof full, "%s/%s", path, de->d_name); + rm_rf(full); + } + closedir(d); + return rmdir(path); +} + +static const mr_library_entry *find_entry(const mr_library *lib, const char *name) +{ + for (int i = 0; i < lib->count; i++) + if (strcmp(lib->items[i].name, name) == 0) return &lib->items[i]; + return NULL; +} + +static void test_natural(void) +{ + CHECK(mr_natural_cmp("page2", "page10") < 0, "page2 < page10"); + CHECK(mr_natural_cmp("page10", "page2") > 0, "page10 > page2"); + CHECK(mr_natural_cmp("page2", "page02") < 0, "fewer leading zeros first"); + CHECK(mr_natural_cmp("x001", "x1") > 0, "x1 < x001"); + CHECK(mr_natural_cmp("Chapter 9", "Chapter 10") < 0, "chapter 9 < 10"); + CHECK(mr_natural_cmp("img2a", "img10b") < 0, "mixed img2a < img10b"); + CHECK(mr_natural_cmp("a", "a1") < 0, "prefix shorter first"); + CHECK(mr_natural_cmp("abc", "abc") == 0, "identical equal"); + CHECK(mr_natural_cmp("ABC", "abc") != 0, "case-only tie is deterministic"); + CHECK(mr_natural_cmp("A2", "a10") < 0, "case-insensitive primary then numeric"); +} + +static void test_ext(void) +{ + CHECK(mr_path_is_image("a.png") == 1, "png"); + CHECK(mr_path_is_image("a.JPG") == 1, "JPG uppercase"); + CHECK(mr_path_is_image("a.jpeg") == 1, "jpeg"); + CHECK(mr_path_is_image("a.BmP") == 1, "BMP mixed case"); + CHECK(mr_path_is_image("a.gif") == 0, "gif unsupported"); + CHECK(mr_path_is_image("a.txt") == 0, "txt unsupported"); + CHECK(mr_path_is_image("png") == 0, "no extension"); + CHECK(mr_path_is_image(".png") == 0, "dotfile-ish no stem"); +} + +static void test_parent(void) +{ + char out[MR_PATH_MAX]; + CHECK(mr_path_parent("/usr/data/mnt/sd_0/a/b", out, sizeof out) == 0 && + strcmp(out, "/usr/data/mnt/sd_0/a") == 0, "parent nested"); + CHECK(mr_path_parent("/usr/data/mnt/sd_0/a/", out, sizeof out) == 0 && + strcmp(out, "/usr/data/mnt/sd_0") == 0, "parent trailing slash"); + CHECK(mr_path_parent("/a", out, sizeof out) == 0 && strcmp(out, "/") == 0, "parent root child"); + CHECK(mr_path_parent("/", out, sizeof out) == 0 && strcmp(out, "/") == 0, "parent root"); +} + +static void test_scan(void) +{ + char tmpl[] = "./mr_test_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return; + + char p[MR_PATH_MAX]; + + snprintf(p, sizeof p, "%s/Series 10", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/Series 10/page1.png", root); touch_file(p); + snprintf(p, sizeof p, "%s/Series 10/page2.jpg", root); touch_file(p); + + snprintf(p, sizeof p, "%s/Series 2", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/Series 2/page10.png", root); touch_file(p); + + snprintf(p, sizeof p, "%s/Series 02", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/Series 02/page1.bmp", root); touch_file(p); + + snprintf(p, sizeof p, "%s/Series 3", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/Series 3/page1.gif", root); touch_file(p); + + snprintf(p, sizeof p, "%s/Empty", root); mkdir_p(p); + + snprintf(p, sizeof p, "%s/Nested/Deeper", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/Nested/Deeper/a.png", root); touch_file(p); + + snprintf(p, sizeof p, "%s/.hidden", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/.hidden/h.png", root); touch_file(p); + + snprintf(p, sizeof p, "%s/MeowAPPs", root); mkdir_p(p); + snprintf(p, sizeof p, "%s/MeowAPPs/m.png", root); touch_file(p); + + snprintf(p, sizeof p, "%s/root.png", root); touch_file(p); /* 根目录直接图片不成为行 */ + + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + CHECK(n == 4, "scan count=%d (want 4)", n); + CHECK(lib.count == n, "count matches"); + if (lib.count == 4) { + CHECK(strcmp(lib.items[0].name, "Nested") == 0, "sort[0]=Nested got %s", lib.items[0].name); + CHECK(strcmp(lib.items[1].name, "Series 2") == 0, "sort[1]=Series 2 got %s", lib.items[1].name); + CHECK(strcmp(lib.items[2].name, "Series 02") == 0, "sort[2]=Series 02 got %s", lib.items[2].name); + CHECK(strcmp(lib.items[3].name, "Series 10") == 0, "sort[3]=Series 10 got %s", lib.items[3].name); + } + + const mr_library_entry *e = find_entry(&lib, "Series 10"); + CHECK(e && e->pages == 2 && e->has_images == 1 && e->subdirs == 0, "Series 10 pages=2"); + CHECK(e && strcmp(e->subtitle, "2 pages") == 0, "Series 10 subtitle '%s'", e ? e->subtitle : ""); + CHECK(e && strstr(e->cover, "page1.png") != NULL, "Series 10 cover natural-first '%s'", + e ? e->cover : ""); + + e = find_entry(&lib, "Series 2"); + CHECK(e && e->pages == 1 && e->has_images == 1, "Series 2 pages=1"); + + e = find_entry(&lib, "Nested"); + CHECK(e && e->pages == 0 && e->has_images == 0 && e->subdirs == 1, "Nested intermediate"); + CHECK(e && strcmp(e->subtitle, "1 folder") == 0, "Nested subtitle '%s'", e ? e->subtitle : ""); + CHECK(e && strstr(e->cover, "Deeper/a.png") != NULL, "Nested cover from descendant '%s'", + e ? e->cover : ""); + + CHECK(find_entry(&lib, "Empty") == NULL, "empty dir excluded"); + CHECK(find_entry(&lib, "Series 3") == NULL, "gif-only dir excluded"); + CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded"); + CHECK(find_entry(&lib, "MeowAPPs") == NULL, "system dir excluded"); + + /* 深度 1:Nested 不再可探测到 Deeper 的图,应被排除。 */ + mr_library lib1; + memset(&lib1, 0, sizeof lib1); + CHECK(mr_library_scan(&lib1, root, 1) == 3, "max_depth=1 excludes Nested"); + mr_library_free(&lib1); + + mr_library_free(&lib); + + /* 不可读目录:返回 -1,lib 可安全 free。 */ + mr_library bad; + memset(&bad, 0, sizeof bad); + CHECK(mr_library_scan(&bad, "/no/such/dir/tuku", MR_LIB_MAX_DEPTH) == -1, "missing dir -> -1"); + CHECK(bad.count == 0, "missing dir empty"); + mr_library_free(&bad); + + rm_rf(root); +} + +static void test_direction(void) +{ + mr_reader_set_direction(MR_DIR_RTL); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "default RTL"); + mr_reader_set_direction(MR_DIR_LTR); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "switch LTR"); + mr_reader_set_direction(99); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "invalid ignored"); + mr_reader_set_direction(MR_DIR_RTL); +} + +int main(void) +{ + test_natural(); + test_ext(); + test_parent(); + test_scan(); + test_direction(); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/tuku_internal.h b/tuku/tuku_internal.h new file mode 100644 index 0000000..caad8db --- /dev/null +++ b/tuku/tuku_internal.h @@ -0,0 +1,64 @@ +/* tuku_internal.h — 图库应用内部共享头(只 extern 声明,不定义)。 + * + * 分层(照 examples/hello): + * - logic/ 纯 C(library 扫描/自然排序、reader 方向状态),不依赖 meow_ui.h,宿主可裸测; + * - ui/ 每页一个 view 文件(library/reader/settings/lock),只碰 SDK; + * - app.c main + 装配 + meow_ui_run 回调路由 + 主题监听。 + * + * 页面:ACT_LIBRARY(0) 根页(保留状态栏)、ACT_READER(1) 全屏、ACT_SETTINGS(2)、ACT_LOCK(3)。 + * 并行开发接缝(后续任务只填自己的文件,不改 app.c/internal.h): + * - M4 阅读器:ui/reader/reader.c 实现,方向状态用 logic/reader 的 mr_reader_get/set_direction; + * 书库点入含图目录经 mr_reader_open_dir(path) 进入本页。 + * - M5 锁屏/PIN:ui/lock/lock.c 实现 lock_view_should_show();本批桩返回 0(不锁)。 + */ +#ifndef TUKU_INTERNAL_H +#define TUKU_INTERNAL_H + +#include + +#include "meow_ui.h" +#include "meow_ui_view.h" +#include "logic/library.h" +#include "logic/reader.h" + +/* ---- Activity 归属(稳定 id,< MEOW_VIEW_BY_ID_MAX) ---- */ +#define ACT_LIBRARY 0 +#define ACT_READER 1 +#define ACT_SETTINGS 2 +#define ACT_LOCK 3 + +#define MR_ROOT_DIR "/usr/data/mnt/sd_0" /* 书库根 = TF 卡根 */ + +/* ---- 视图控制器(biz-UI 绑定点) ---- */ +typedef struct mr_page_ctrl { + meow_view *view; + int active; +} mr_page_ctrl; + +/* ---- 书库页(ui/library/library.c) ---- */ +meow_view *library_view_create(void); +int library_at_root(void); /* 1=当前在 MR_ROOT_DIR */ +int library_go_up(void); /* 返回上一级;1=已上移,0=已在根 */ +void library_on_tap(int x, int y); +void library_on_swipe(int dir, int dist); /* dist=SDK on_swipe 的位移量(UP/DOWN 用) */ +void library_on_touch_down(int x, int y); +void library_on_touch_move(int x, int y); +void library_on_tick(void); /* 收下后台缩略图结果并局部补位 */ + +/* ---- 阅读器页(ui/reader/reader.c,M4 填充) ---- */ +meow_view *reader_view_create(void); +void reader_on_tap(int x, int y); +void mr_reader_open_dir(const char *path); /* 书库→阅读器的桩导航接口 */ +const char *mr_reader_dir(void); /* 当前待打开目录(骨架显示用) */ + +/* ---- 设置页(ui/settings/settings.c) ---- */ +meow_view *settings_view_create(void); + +/* ---- 锁屏页(ui/lock/lock.c,M5 填充) ---- */ +meow_view *lock_view_create(void); +int lock_view_should_show(void); /* 1=启动先显示锁屏;本批桩返回 0 */ + +/* ---- App 级(app.c) ---- */ +void mr_nav_report(void); /* 向 launcher 上报当前页栈深/顶层 */ + +#endif /* TUKU_INTERNAL_H */ diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c new file mode 100644 index 0000000..28fcdf3 --- /dev/null +++ b/tuku/ui/library/library.c @@ -0,0 +1,650 @@ +/* ui/library/library.c — 图库根页(ACT_LIBRARY): + * + * TopAppBar(标题 + 返回 + ⚙ 位图动作)+ meow_listview 虚拟化目录列表。 + * 任意文件夹可浏览;无元数据但含 PNG/JPEG/BMP 的文件夹即一本书(含图目录=册)。 + * 行=缩略图 + 目录名 + 页数;目录由 logic/library 扫描并自然排序。 + * + * 流畅度(用户指令优先): + * - 首帧只绘文字行:bind 不读盘;缩略图请求投递给后台线程(pthread)异步解码, + * 完成后在 on_tick 补位并只 invalidate 列表视口; + * - 缩略图 meow_img_downscale 到 96px,并落磁盘缓存 MeowData//cache + * (BMP 原始缩略图,文件名含源文件 mtime+size,源文件变化自动失效;≥64 张按 + * mtime LRU 删除); + * - 列表滚动用 meow_scroll_wrap:跟手 track + 松手 fling + 行高 snap。 + * + * 绘制/颜色:SDK 注册表非保留绘制,全部取 meow_theme_get() 语义色;主题切换由 app.c 的 + * listener 整帧 invalidate。缩略图缓存只在列表重建时释放(避免池内悬垂指针),上限 24 张。 + */ +#include "tuku_internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MR_THUMB_MAX 24 +#define MR_THUMB_DIM 96 +#define MR_TOPAPPBAR_H 56 +#define MR_DEFAULT_ROW_H 56 +#define MR_CACHE_MAX_FILES 64 + +#define MR_REQ_MAX 64 +#define MR_RES_MAX 16 + +typedef struct thumb_req { + int gen; + int idx; + char cover[MR_PATH_MAX]; +} thumb_req; + +typedef struct thumb_res { + int gen; + int idx; + meow_img *img; +} thumb_res; + +static mr_page_ctrl g_ctrl; +static mr_library g_lib; +static char g_cur_dir[MR_PATH_MAX] = MR_ROOT_DIR; + +static meow_widget *g_tb; +static meow_widget *g_lv; +static meow_scroll *g_scroll; +static int g_list_x, g_list_y, g_list_w, g_list_h; + +static struct { + int idx; + meow_img *img; +} g_thumbs[MR_THUMB_MAX]; +static int g_thumb_n; + +/* ---- 异步缩略图请求/结果队列 ---- */ +static pthread_t g_worker; +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER; +static int g_worker_on; +static int g_worker_stop; +static int g_gen; + +static thumb_req g_req[MR_REQ_MAX]; +static int g_req_head, g_req_tail, g_req_n; +static char g_inflight[MR_PATH_MAX]; + +static thumb_res g_res[MR_RES_MAX]; +static int g_res_n; + +/* ---- 磁盘缩略图缓存 ---- */ +static char g_cache_dir[MR_PATH_MAX]; +static int g_cache_on; + +static int g_drag; +static int g_dragged; +static int g_drag_last_y; + +/* ============================================================ */ +/* 磁盘缓存(BMP 原始缩略图;文件名含源 mtime+size 做失效) */ +/* ============================================================ */ +static unsigned long long fnv1a64(const char *s) +{ + unsigned long long h = 1469598103934665603ULL; + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + h ^= (unsigned long long)*p; + h *= 1099511628211ULL; + } + return h; +} + +static void cache_init(void) +{ + const char *data = meow_config_dir(); + if (!data || !data[0]) return; + + int n = snprintf(g_cache_dir, sizeof g_cache_dir, "%s", data); + if (n <= 0 || n >= (int)sizeof g_cache_dir) { g_cache_dir[0] = '\0'; return; } + + char *slash = strrchr(g_cache_dir, '/'); + if (slash && strcmp(slash, "/data") == 0) *slash = '\0'; + + size_t len = strlen(g_cache_dir); + n = snprintf(g_cache_dir + len, sizeof g_cache_dir - len, "/cache"); + if (n <= 0 || n >= (int)(sizeof g_cache_dir - len)) { g_cache_dir[0] = '\0'; return; } + + if (mkdir(g_cache_dir, 0755) != 0 && errno != EEXIST) { + g_cache_dir[0] = '\0'; + return; + } + g_cache_on = 1; +} + +static void cache_path(char *out, int outsz, const char *cover, const struct stat *st) +{ + unsigned long long h = fnv1a64(cover); + snprintf(out, (size_t)outsz, "%s/%016llx_%lld_%lld.bmp", g_cache_dir, h, + (long long)st->st_mtime, (long long)st->st_size); +} + +/* LRU:缓存文件数超过 MR_CACHE_MAX_FILES 时删最旧(读命中会 utimes 刷新 mtime)。 + * 每次只删一个最旧文件、最多扫 8 轮,避免在 14MB 内存口径下分配大数组。 */ +static void cache_prune(void) +{ + if (!g_cache_on || !g_cache_dir[0]) return; + for (int pass = 0; pass < 8; pass++) { + DIR *d = opendir(g_cache_dir); + if (!d) return; + int count = 0, found = 0; + char oldest[MR_PATH_MAX + 96]; + oldest[0] = '\0'; + time_t oldest_t = 0; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *dot = strrchr(de->d_name, '.'); + if (!dot || strcmp(dot, ".bmp") != 0) continue; + char full[MR_PATH_MAX + 96]; + int n = snprintf(full, sizeof full, "%s/%s", g_cache_dir, de->d_name); + if (n <= 0 || n >= (int)sizeof full) continue; + struct stat st; + if (stat(full, &st) != 0) continue; + count++; + if (!found || st.st_mtime < oldest_t) { + found = 1; + oldest_t = st.st_mtime; + snprintf(oldest, sizeof oldest, "%s", full); + } + } + closedir(d); + if (count <= MR_CACHE_MAX_FILES || !found) break; + unlink(oldest); + } +} + +static void put_u16(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)(v & 0xFF); + p[1] = (unsigned char)((v >> 8) & 0xFF); +} +static void put_u32(unsigned char *p, uint32_t v) +{ + p[0] = (unsigned char)(v & 0xFF); + p[1] = (unsigned char)((v >> 8) & 0xFF); + p[2] = (unsigned char)((v >> 16) & 0xFF); + p[3] = (unsigned char)((v >> 24) & 0xFF); +} + +/* 写 32bpp BI_RGB BMP(stb_image 可读回)。失败返回 -1(缓存只是加速,静默降级)。 */ +static int write_bmp(const char *path, const meow_img *im) +{ + int w = meow_img_w(im), h = meow_img_h(im); + const unsigned char *px = meow_img_pixels(im); + if (!path || w <= 0 || h <= 0 || w > MR_THUMB_DIM || h > MR_THUMB_DIM || !px) return -1; + + uint32_t stride = (uint32_t)w * 4; + uint32_t imgsz = stride * (uint32_t)h; + unsigned char fh[14] = { 0 }; + unsigned char ih[40] = { 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put_u32(fh + 2, 14 + 40 + imgsz); + put_u32(fh + 10, 14 + 40); + put_u32(ih + 0, 40); + put_u32(ih + 4, (uint32_t)w); + put_u32(ih + 8, (uint32_t)h); + put_u16(ih + 12, 1); + put_u16(ih + 14, 32); + put_u32(ih + 20, imgsz); + put_u32(ih + 24, 2835); + put_u32(ih + 28, 2835); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = (fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih); + unsigned char row[MR_THUMB_DIM * 4]; + for (int y = h - 1; ok && y >= 0; y--) { + const unsigned char *src = px + (size_t)y * (size_t)w * 4; + for (int x = 0; x < w; x++) { + row[x * 4 + 0] = src[x * 4 + 2]; + row[x * 4 + 1] = src[x * 4 + 1]; + row[x * 4 + 2] = src[x * 4 + 0]; + row[x * 4 + 3] = src[x * 4 + 3]; + } + if (fwrite(row, 1, stride, f) != stride) ok = 0; + } + if (fclose(f) != 0) ok = 0; + if (!ok) unlink(path); + return ok ? 0 : -1; +} + +/* 后台线程:缓存命中直接读;否则解码源图 → downscale → 落缓存。 */ +static meow_img *thumb_load(const char *cover) +{ + struct stat st; + if (stat(cover, &st) != 0 || !S_ISREG(st.st_mode)) return NULL; + + char cp[MR_PATH_MAX + 96]; + if (g_cache_on && g_cache_dir[0]) { + cache_path(cp, (int)sizeof cp, cover, &st); + if (access(cp, R_OK) == 0) { + meow_img *im = meow_img_load_file(cp); + if (im) { + utimes(cp, NULL); /* LRU 读命中刷新 */ + return im; + } + unlink(cp); /* 损坏缓存 */ + } + } + + meow_img *im = meow_img_load_file(cover); + if (!im) return NULL; + meow_img *small = meow_img_downscale(im, MR_THUMB_DIM); + if (small) { meow_img_free(im); im = small; } + + if (g_cache_on && g_cache_dir[0]) { + if (write_bmp(cp, im) == 0) cache_prune(); + } + return im; +} + +static void *thumb_worker(void *arg) +{ + (void)arg; + for (;;) { + thumb_req req; + pthread_mutex_lock(&g_lock); + while (!g_worker_stop && g_req_n == 0) + pthread_cond_wait(&g_cv, &g_lock); + if (g_worker_stop) { pthread_mutex_unlock(&g_lock); break; } + req = g_req[g_req_head]; + g_req_head = (g_req_head + 1) % MR_REQ_MAX; + g_req_n--; + snprintf(g_inflight, sizeof g_inflight, "%s", req.cover); + pthread_mutex_unlock(&g_lock); + + meow_img *im = thumb_load(req.cover); + + pthread_mutex_lock(&g_lock); + g_inflight[0] = '\0'; + if (im && !g_worker_stop && g_res_n < MR_RES_MAX) { + g_res[g_res_n].gen = req.gen; + g_res[g_res_n].idx = req.idx; + g_res[g_res_n].img = im; + g_res_n++; + } else if (im) { + meow_img_free(im); + } + pthread_mutex_unlock(&g_lock); + } + return NULL; +} + +static void worker_start(void) +{ + if (g_worker_on) return; + g_worker_stop = 0; + if (pthread_create(&g_worker, NULL, thumb_worker, NULL) == 0) g_worker_on = 1; +} + +static void worker_stop(void) +{ + if (!g_worker_on) return; + pthread_mutex_lock(&g_lock); + g_worker_stop = 1; + pthread_cond_broadcast(&g_cv); + pthread_mutex_unlock(&g_lock); + pthread_join(g_worker, NULL); + g_worker_on = 0; + + pthread_mutex_lock(&g_lock); + for (int i = 0; i < g_res_n; i++) meow_img_free(g_res[i].img); + g_res_n = 0; + g_req_n = 0; + g_req_head = g_req_tail = 0; + pthread_mutex_unlock(&g_lock); +} + +/* 请求缩略图:bind 只投递,不阻塞首绘/滚动。 */ +static void thumb_enqueue(int idx, const char *cover) +{ + if (!g_worker_on || !cover || !cover[0]) return; + pthread_mutex_lock(&g_lock); + if (g_worker_stop) { pthread_mutex_unlock(&g_lock); return; } + if (strcmp(g_inflight, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } + for (int i = 0; i < g_req_n; i++) { + int p = (g_req_head + i) % MR_REQ_MAX; + if (strcmp(g_req[p].cover, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } + } + if (g_req_n >= MR_REQ_MAX) { pthread_mutex_unlock(&g_lock); return; } + g_req[g_req_tail].gen = g_gen; + g_req[g_req_tail].idx = idx; + snprintf(g_req[g_req_tail].cover, sizeof g_req[g_req_tail].cover, "%s", cover); + g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; + g_req_n++; + pthread_cond_signal(&g_cv); + pthread_mutex_unlock(&g_lock); +} + +/* ============================================================ */ +/* 缩略图内存缓存 */ +/* ============================================================ */ +static void thumbs_free(void) +{ + for (int i = 0; i < g_thumb_n; i++) meow_img_free(g_thumbs[i].img); + g_thumb_n = 0; +} + +static meow_img *thumb_find(int idx) +{ + for (int i = 0; i < g_thumb_n; i++) + if (g_thumbs[i].idx == idx) return g_thumbs[i].img; + return NULL; +} + +static int thumb_put(int idx, meow_img *im) +{ + if (g_thumb_n >= MR_THUMB_MAX) return 0; + g_thumbs[g_thumb_n].idx = idx; + g_thumbs[g_thumb_n].img = im; + g_thumb_n++; + return 1; +} + +/* ============================================================ */ +/* 列表虚拟化数据源 */ +/* ============================================================ */ +static int lib_count(void *ctx) +{ + (void)ctx; + return g_lib.count; +} + +static void lib_bind(int idx, meow_lv_item *item, void *ctx) +{ + (void)ctx; + memset(item, 0, sizeof *item); + if (idx < 0 || idx >= g_lib.count) return; + mr_library_entry *e = &g_lib.items[idx]; + item->title = e->name; + item->subtitle = e->subtitle; + item->right = MEOW_LIST_RIGHT_ARROW; + item->enabled = 1; + item->user = e; + + meow_img *im = thumb_find(idx); + if (im) { + item->icon_kind = MEOW_LIST_ICON_IMG; + item->icon = im; + } else { + item->icon_kind = e->has_images ? MEOW_LIST_ICON_FILE : MEOW_LIST_ICON_FOLDER; + if (e->cover[0]) thumb_enqueue(idx, e->cover); + } +} + +/* ============================================================ */ +/* 布局 / 列表重建 */ +/* ============================================================ */ +static int lib_top_y(void) +{ + return meow_ui_statusbar_height() + MR_TOPAPPBAR_H; +} + +static void list_rebuild(void) +{ + if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } + if (g_lv) { meow_widget_free(g_lv); g_lv = NULL; } + g_list_x = 0; + g_list_y = lib_top_y(); + g_list_w = meow_ui_width(); + g_list_h = meow_ui_height() - g_list_y; + if (g_list_h < 1) g_list_h = 1; + + g_lv = meow_listview_create(g_list_x, g_list_y, g_list_w, g_list_h, 0, NULL, NULL, + MEOW_LIST_SEL_NONE); + if (!g_lv) return; + meow_listview_set_vsource(g_lv, lib_count, lib_bind, NULL); + meow_listview_set_item_renderer(g_lv, 2, 1, 1); + meow_listview_set_item_tier(g_lv, 2); + meow_listview_set_empty_text(g_lv, "No folders with PNG/JPEG/BMP"); + + g_scroll = meow_scroll_wrap(g_lv, 1); + if (g_scroll) meow_scroll_set_snap(g_scroll, MR_DEFAULT_ROW_H); +} + +static void update_topbar(void) +{ + if (!g_tb) return; + const char *title = "图库"; + if (strcmp(g_cur_dir, MR_ROOT_DIR) != 0) { + const char *slash = strrchr(g_cur_dir, '/'); + if (slash && slash[1]) title = slash + 1; + } + meow_topappbar_set_title(g_tb, title); + meow_topappbar_set_back(g_tb, strcmp(g_cur_dir, MR_ROOT_DIR) != 0); +} + +static void list_invalidate(void) +{ + meow_ui_invalidate(g_list_x, g_list_y, g_list_w, g_list_h); +} + +/* 目录切换:作废旧异步结果、释放内存缩略图、重新扫描、重建列表(新池强制重新 bind)。 */ +static void reset_async(void) +{ + pthread_mutex_lock(&g_lock); + g_gen++; + g_req_n = 0; + g_req_head = g_req_tail = 0; + for (int i = 0; i < g_res_n; i++) meow_img_free(g_res[i].img); + g_res_n = 0; + pthread_mutex_unlock(&g_lock); +} + +static void load_dir(const char *dir) +{ + snprintf(g_cur_dir, sizeof g_cur_dir, "%s", dir); + reset_async(); + thumbs_free(); + if (mr_library_scan(&g_lib, g_cur_dir, MR_LIB_MAX_DEPTH) < 0) { + mr_library_free(&g_lib); + meow_log(MEOW_LOG_WARN, "library scan failed: %s", g_cur_dir); + } + list_rebuild(); + update_topbar(); + g_drag = 0; + g_dragged = 0; + if (g_ctrl.active) + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); +} + +int library_at_root(void) +{ + return strcmp(g_cur_dir, MR_ROOT_DIR) == 0; +} + +int library_go_up(void) +{ + if (library_at_root()) return 0; + char parent[MR_PATH_MAX]; + if (mr_path_parent(g_cur_dir, parent, (int)sizeof parent) != 0) return 0; + /* 不越过书库根(父级仍须在根目录之下或等于根)。 */ + if (strcmp(parent, MR_ROOT_DIR) != 0 && + strncmp(parent, MR_ROOT_DIR "/", sizeof(MR_ROOT_DIR)) != 0) + snprintf(parent, sizeof parent, "%s", MR_ROOT_DIR); + load_dir(parent); + return 1; +} + +/* ============================================================ */ +/* 事件 */ +/* ============================================================ */ +void library_on_tap(int x, int y) +{ + if (!g_ctrl.active) return; + g_drag = 0; + g_dragged = 0; + + if (g_tb) { + int hit = meow_topappbar_hit(g_tb, x, y); + if (hit == MEOW_TOPBAR_BACK) { library_go_up(); return; } + if (hit >= 0) { meow_view_page_push(ACT_SETTINGS); return; } + } + if (!g_lv) return; + int idx = meow_listview_hit(g_lv, x, y); + if (idx < 0 || idx >= g_lib.count) return; + mr_library_entry *e = &g_lib.items[idx]; + if (e->has_images) mr_reader_open_dir(e->path); + else load_dir(e->path); +} + +void library_on_swipe(int dir, int dist) +{ + if (!g_ctrl.active || !g_lv) return; + if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return; + if (g_dragged) { /* 本次手势已实时跟手滚动,抬手只补 fling */ + g_dragged = 0; + g_drag = 0; + if (g_scroll) meow_scroll_fling(g_scroll); + return; + } + int del = (dir == MEOW_SWIPE_UP) ? dist : -dist; + if (del == 0) del = (dir == MEOW_SWIPE_UP) ? 120 : -120; + if (g_scroll) { + meow_scroll_track(g_scroll, del, meow_ui_time_ms()); + meow_scroll_fling(g_scroll); + } else { + meow_listview_scroll_by(g_lv, del); + } + list_invalidate(); +} + +void library_on_touch_down(int x, int y) +{ + if (!g_ctrl.active || !g_lv) return; + /* 屏底 28px 是系统「上滑回桌面」热区:不从这里启动列表拖拽,避免拦截系统手势。 */ + if (y >= meow_ui_height() - 28) return; + if (x < g_list_x || x >= g_list_x + g_list_w || + y < g_list_y || y >= g_list_y + g_list_h) return; + g_drag = 1; + g_dragged = 0; + g_drag_last_y = y; +} + +void library_on_touch_move(int x, int y) +{ + (void)x; + if (!g_ctrl.active || !g_drag || !g_lv) return; + int dy = g_drag_last_y - y; + if (dy == 0) return; + if (dy > 1 || dy < -1) g_dragged = 1; + g_drag_last_y = y; + if (g_scroll) meow_scroll_track(g_scroll, dy, meow_ui_time_ms()); + else meow_listview_scroll_by(g_lv, dy); + list_invalidate(); +} + +/* 16ms tick:收下后台解码好的缩略图并局部补位。 */ +void library_on_tick(void) +{ + if (!g_ctrl.active) return; + thumb_res got[MR_RES_MAX]; + int n = 0; + pthread_mutex_lock(&g_lock); + n = g_res_n; + for (int i = 0; i < n; i++) got[i] = g_res[i]; + g_res_n = 0; + pthread_mutex_unlock(&g_lock); + + int changed = 0; + for (int i = 0; i < n; i++) { + if (got[i].gen == g_gen && got[i].idx >= 0 && got[i].idx < g_lib.count && + !thumb_find(got[i].idx) && thumb_put(got[i].idx, got[i].img)) + changed = 1; + else + meow_img_free(got[i].img); + } + if (changed) list_invalidate(); +} + +/* ============================================================ */ +/* view 生命周期 */ +/* ============================================================ */ +static void library_page_create(meow_view *v, void *ctx) +{ + mr_page_ctrl *c = (mr_page_ctrl *)ctx; + c->view = v; + + cache_init(); + worker_start(); + + int w = meow_ui_width(); + int sb = meow_ui_statusbar_height(); + g_tb = meow_topappbar_create(0, sb, w, MR_TOPAPPBAR_H, "图库"); + if (g_tb) { + int ai = meow_topappbar_add_action(g_tb, ""); + if (ai >= 0) meow_topappbar_set_item_icon(g_tb, ai, MEOW_ICON_SETTINGS); + } + load_dir(MR_ROOT_DIR); +} + +static void library_page_enter(meow_view *v, void *ctx) +{ + ((mr_page_ctrl *)ctx)->active = 1; + v->x = 0; + v->y = 0; + v->w = meow_ui_width(); + v->h = meow_ui_height(); + meow_ui_set_fullscreen(0); /* 图库保留状态栏(reader 退出时若未复原也在此兜底) */ + meow_ui_set_page_bg(meow_theme_get()->bg); + + int sb = meow_ui_statusbar_height(); + if (g_tb) meow_widget_set_rect(g_tb, 0, sb, meow_ui_width(), MR_TOPAPPBAR_H); + if (g_lv) { + int y = sb + MR_TOPAPPBAR_H; + int h = meow_ui_height() - y; + if (h < 1) h = 1; + meow_widget_set_rect(g_lv, 0, y, meow_ui_width(), h); + g_list_y = y; + g_list_w = meow_ui_width(); + g_list_h = h; + } + g_drag = 0; + g_dragged = 0; + update_topbar(); + mr_nav_report(); + meow_ui_paint_full(); +} + +static void library_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((mr_page_ctrl *)ctx)->active = 0; + g_drag = 0; + g_dragged = 0; + if (g_scroll) meow_scroll_stop(g_scroll); +} + +static void library_page_destroy(meow_view *v, void *ctx) +{ + (void)v; (void)ctx; + worker_stop(); + if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } + if (g_lv) { meow_widget_free(g_lv); g_lv = NULL; } + if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } + thumbs_free(); + mr_library_free(&g_lib); +} + +meow_view *library_view_create(void) +{ + static const meow_view_hooks h = { + .on_create = library_page_create, + .on_enter = library_page_enter, + .on_leave = library_page_leave, + .on_destroy = library_page_destroy, + }; + return meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); +} diff --git a/tuku/ui/lock/lock.c b/tuku/ui/lock/lock.c new file mode 100644 index 0000000..0fd5626 --- /dev/null +++ b/tuku/ui/lock/lock.c @@ -0,0 +1,55 @@ +/* ui/lock/lock.c — 锁屏页(ACT_LOCK)M3 桩 + M5 接缝。 + * + * M5 在此实现「APP 启动 PIN」:lock_view_should_show() 读配置决定是否锁, + * 锁屏页负责 PIN 输入/校验后 page_pop 回书库。本批桩恒返回 0(不锁),页面仅可编译占位。 + */ +#include "tuku_internal.h" + +static mr_page_ctrl g_ctrl; + +int lock_view_should_show(void) +{ + return 0; /* M5:读 APP 启动 PIN 配置后返回 1 */ +} + +static void lock_draw(meow_view *v, const meow_theme *th, void *ctx) +{ + (void)v; (void)ctx; + int w = meow_ui_width(); + int h = meow_ui_height(); + int cy = h / 2; + + meow_ui_text_center(w / 2, cy - 80, "LOCKED", th->font_title, th->primary); + meow_ui_text_center(w / 2, cy - 36, "Startup PIN (M5)", th->font_body, th->onSurface); + meow_ui_text_center(w / 2, cy + 8, "PIN entry will be implemented in M5", + th->font_body, th->onSurfaceVariant); +} + +static void lock_page_enter(meow_view *v, void *ctx) +{ + ((mr_page_ctrl *)ctx)->active = 1; + v->x = 0; + v->y = 0; + v->w = meow_ui_width(); + v->h = meow_ui_height(); + meow_ui_set_fullscreen(0); + meow_ui_set_page_bg(meow_theme_get()->bg); + mr_nav_report(); + meow_ui_paint_full(); +} + +static void lock_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((mr_page_ctrl *)ctx)->active = 0; +} + +meow_view *lock_view_create(void) +{ + static const meow_view_hooks h = { + .on_enter = lock_page_enter, + .on_leave = lock_page_leave, + .on_draw = lock_draw, + }; + return meow_view_create(ACT_LOCK, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); +} diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c new file mode 100644 index 0000000..1e2ebe6 --- /dev/null +++ b/tuku/ui/reader/reader.c @@ -0,0 +1,85 @@ +/* ui/reader/reader.c — 阅读器页(ACT_READER,全屏)M3 骨架。 + * + * M4 在此实现翻页/图片显示;本批只落可编译桩 + 既有接缝: + * - 书库经 mr_reader_open_dir(path) 导航进来(app.c/library.c 不改); + * - 方向状态走 logic/reader 的 mr_reader_get/set_direction(默认 RTL)。 + * 绘制走 view on_draw(注册表驱动),主题切换由 app.c 的 listener invalidate 全屏重画。 + */ +#include "tuku_internal.h" + +#include +#include + +static mr_page_ctrl g_ctrl; +static char g_dir[MR_PATH_MAX]; + +const char *mr_reader_dir(void) +{ + return g_dir; +} + +void mr_reader_open_dir(const char *path) +{ + if (!path) return; + snprintf(g_dir, sizeof g_dir, "%s", path); + meow_log(MEOW_LOG_INFO, "reader open: %s", g_dir); + meow_view_page_push(ACT_READER); +} + +void reader_on_tap(int x, int y) +{ + (void)x; (void)y; + if (!g_ctrl.active) return; + mr_reader_set_direction(mr_reader_get_direction() == MR_DIR_RTL ? MR_DIR_LTR : MR_DIR_RTL); + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); +} + +static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) +{ + (void)v; (void)ctx; + int w = meow_ui_width(); + const char *dir = g_dir[0] ? g_dir : "(no directory)"; + size_t n = strlen(dir); + if (n > 64) dir += n - 64; + + meow_ui_text_center(w / 2, 160, "READER", th->font_title, th->primary); + meow_ui_text_center(w / 2, 200, "(M4 will render pages here)", th->font_body, + th->onSurfaceVariant); + meow_ui_text_center(w / 2, 280, "Folder:", th->font_body, th->onSurfaceVariant); + meow_ui_text_center(w / 2, 308, dir, th->font_body, th->onSurface); + meow_ui_text_center(w / 2, 372, + mr_reader_get_direction() == MR_DIR_RTL ? "Direction: RTL" : "Direction: LTR", + th->font_body, th->onSurface); + meow_ui_text_center(w / 2, 420, "tap = toggle direction", th->font_body, th->onSurfaceVariant); + meow_ui_text_center(w / 2, 448, "left-edge swipe = back", th->font_body, th->onSurfaceVariant); +} + +static void reader_page_enter(meow_view *v, void *ctx) +{ + ((mr_page_ctrl *)ctx)->active = 1; + v->x = 0; + v->y = 0; + v->w = meow_ui_width(); + v->h = meow_ui_height(); + meow_ui_set_fullscreen(1); /* app.json route.reader.fullscreen 的运行时兜底 */ + meow_ui_set_page_bg(meow_theme_get()->bg); + mr_nav_report(); + meow_ui_paint_full(); +} + +static void reader_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((mr_page_ctrl *)ctx)->active = 0; + meow_ui_set_fullscreen(0); +} + +meow_view *reader_view_create(void) +{ + static const meow_view_hooks h = { + .on_enter = reader_page_enter, + .on_leave = reader_page_leave, + .on_draw = reader_draw, + }; + return meow_view_create(ACT_READER, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); +} diff --git a/tuku/ui/settings/settings.c b/tuku/ui/settings/settings.c new file mode 100644 index 0000000..57e1a4f --- /dev/null +++ b/tuku/ui/settings/settings.c @@ -0,0 +1,55 @@ +/* ui/settings/settings.c — 设置页(ACT_SETTINGS)M3 骨架。 + * + * M5 在此实现 APP 启动 PIN 等设置项;本批只落可编译桩,入口为书库 TopAppBar ⚙。 + * 返回:左缘返回(app.c SWIPE_LEFT → page_pop)。 + */ +#include "tuku_internal.h" + +static mr_page_ctrl g_ctrl; + +static void settings_draw(meow_view *v, const meow_theme *th, void *ctx) +{ + (void)v; (void)ctx; + int w = meow_ui_width(); + int sb = meow_ui_statusbar_height(); + + meow_ui_fill_rect(0, sb, w, 56, th->surfaceContainer); + meow_ui_text(w / 2, sb + 16, "Settings", th->font_title, th->onSurface); + meow_ui_text_center(w / 2, sb + 100, "APP options", th->font_body, th->onSurfaceVariant); + meow_ui_text_center(w / 2, sb + 140, "Startup PIN — M5", th->font_body, th->onSurface); + meow_ui_text_center(w / 2, sb + 180, "Reader direction — M4", th->font_body, + th->onSurface); + meow_ui_text_center(w / 2, sb + 240, "Theme follows the system automatically", + th->font_body, th->onSurfaceVariant); + meow_ui_text_center(w / 2, sb + 300, "left-edge swipe = back", th->font_body, + th->onSurfaceVariant); +} + +static void settings_page_enter(meow_view *v, void *ctx) +{ + ((mr_page_ctrl *)ctx)->active = 1; + v->x = 0; + v->y = 0; + v->w = meow_ui_width(); + v->h = meow_ui_height(); + meow_ui_set_fullscreen(0); + meow_ui_set_page_bg(meow_theme_get()->bg); + mr_nav_report(); + meow_ui_paint_full(); +} + +static void settings_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((mr_page_ctrl *)ctx)->active = 0; +} + +meow_view *settings_view_create(void) +{ + static const meow_view_hooks h = { + .on_enter = settings_page_enter, + .on_leave = settings_page_leave, + .on_draw = settings_draw, + }; + return meow_view_create(ACT_SETTINGS, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); +}