From 9f34417c49c48a10096fd728516d6b1119e73cd3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 11:55:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E5=BA=93:=20=E5=8D=95=E5=B1=82?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=20+=20=E6=87=92=E5=88=A4=E5=AE=9A=E4=B8=8E?= =?UTF-8?q?=20M7/M11=20=E6=95=B4=E5=90=88=EF=BC=9B=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E7=BC=93=E5=AD=98=20P2=20=E4=BF=AE=E5=A4=8D=EF=BC=88M13?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rebase 到 picbrowser(含 M7 条漫/续读、M11 CBZ 档案册)后的语义拼接: - 保留单层扫描重写:一次 opendir/readdir+lstat 只列当前层(目录 + 注入 archive_pages 时的 .cbz/.zip 档案文件),自然升序、隐藏项/符号链接跳过、2000 条截断标注; 系统目录/空目录/无图目录一律列出;彻底删除递归「子树含图」探测 - 保留并复用 M11 档案册分支:add_dir_entry / add_archive_entry / entry_subtitle / entry_badge / archive_pages_at / sibling_is_book / mr_library_scan_ex 注入; 档案册行同样先成行、页数由后台 probe(注入 zip 页数回调)懒算 - 保留 M7:mr_library_dir_has_images / mr_book_adjacent(_ex) 语义不变;为 adjacency 恢复 ignored_dir_name(仅册间衔接跳过系统/隐藏目录,浏览本身不隐藏); mr_library_format_subtitle 在补位后拼「· 续读 Pn」角标 - mr_library_probe_path 统一探测目录/档案册:mtime(秒+纳秒)+size 失效的 256 项 LRU + 互斥锁;评审 P2:只缓存「探测前后 stat 完全一致」的结果,期间变化则不写缓存 (消除「不完整结果绑定新 mtime」的假新鲜;下次调用重新探测) - UI:列表用 src2 每帧取行;后台线程探测优先于缩略图;档案册封面解压档内首图(M11); 行语义=书页数/缩略图、空目录「空目录」、无图/坏档「无图片」;可上溯至文件系统 /、 不可读目录提示并停留;TopAppBar「TF卡」回根;启动时根目录不可读也建空列表; 顶栏回根命中加 g_tb_home_idx>=0 防御 - tuku_internal.h:library_go_up 注释校正(0=到达文件系统 /,app.c 据此退出) - 测试:test_library 改单层语义;新增 browse_nav / browse_lazy / browse_perf / browse_ui_host(含评审 P2 的「探测期间变化不写缓存」用例);test_cont_adjacent A6 改两段式;test_cbz T8/T10 改两段式(坏档/无图档案列出但非书); run_tests.sh 保留双方全部条目(browse 4 套 + run_zip_tests/run_cbz_tests) - 复验:run_tests.sh 全 PASS(含 M11 zip/cbz)、run_webtoon_tests.sh 全 PASS; worktree 内 sdk/build_sdk.sh + build_tuku.sh 零警告静态 MIPS32r2 ELF --- tuku/logic/library.c | 476 +++++++++++++++++++------- tuku/logic/library.h | 153 +++++++-- tuku/test/run_tests.sh | 62 +++- tuku/test/test_browse_lazy.c | 443 ++++++++++++++++++++++++ tuku/test/test_browse_nav.c | 259 ++++++++++++++ tuku/test/test_browse_perf.c | 199 +++++++++++ tuku/test/test_browse_ui_host.c | 269 +++++++++++++++ tuku/test/test_cbz.c | 96 ++++-- tuku/test/test_cont_adjacent.c | 53 ++- tuku/test/test_library.c | 63 ++-- tuku/tuku_internal.h | 2 +- tuku/ui/library/library.c | 582 +++++++++++++++++++++++++------- 12 files changed, 2299 insertions(+), 358 deletions(-) create mode 100644 tuku/test/test_browse_lazy.c create mode 100644 tuku/test/test_browse_nav.c create mode 100644 tuku/test/test_browse_perf.c create mode 100644 tuku/test/test_browse_ui_host.c diff --git a/tuku/logic/library.c b/tuku/logic/library.c index 4db9545..7ab94c0 100644 --- a/tuku/logic/library.c +++ b/tuku/logic/library.c @@ -1,38 +1,57 @@ -/* logic/library.c — 书库目录扫描 + 自然排序(纯 C)。 +/* logic/library.c — 书库目录浏览:单层枚举 + 后台懒判定(纯 C)。 * - * 扫描分两步: - * 1) scan_info():递归探测一个目录是否「最终含图」,并带回直接图片数 / 含图子目录数 / - * 缩略图源(优先本目录自然序第一张,其次第一个含图子目录); - * 2) mr_library_scan():只对目标目录的**直接子目录**调用 scan_info(),把 nav=1 的目录 - * 收进结果并自然排序——因此根视图不会列出后代条目,进入子目录时再重新扫描该层。 + * 扫描(mr_library_scan / mr_library_scan_ex)与 os.meow.files 的 fsx_scan_dir 同构: + * - 一次 opendir/readdir,逐项 lstat(只认目录与档案文件;符号链接跳过防环); + * - 隐藏项("." 开头,含 . ..)跳过;系统目录/空目录/无图目录一律列出; + * - 目录名/档案文件名按 mr_natural_cmp 自然升序;条目到 MR_LIB_SCAN_MAX 后仍有条目 → + * 截断标注。不再对子目录做任何递归/探测——首帧成本只与本层条目数线性相关。 * - * 内存:条目数组倍增分配;路径/名称定长,随 mr_library_free() 释放。 + * 懒判定(probe): + * - mr_library_probe_dir 只读一层(图片项 lstat 校验普通文件),给出直接图片数与自然序首图; + * - mr_library_probe_path 按行类型探测:目录走上面的单层扫描;档案册调调用方注入的 + * archive_pages(M11,logic 不依赖 zip)。两者共用 mtime(秒+纳秒)+size 失效的 256 项 + * LRU 缓存 + 互斥锁,可被后台线程并发调用;探测期间目录/档案发生变化则不写缓存。 + * - mr_library_apply_probe 把结果补回行;副标题由 mr_library_format_subtitle 统一生成, + * 书行再拼 M7 的「· 续读 Pn」角标。 * - * M7 追加(不改扫描语义): - * - 续读角标:含图册的副标题后缀「· 续读 Pn」(读 logic/reader 的每册 last page); - * - 册间衔接:mr_book_adjacent 给出同父目录下按书库自然序的相邻「含图册」, - * 供阅读器末页前进/首页后退跨册;mr_library_dir_has_images 是共享的「是不是一本书」判据。 - * - * M11 追加(档案册,仍然不改目录扫描的主体语义): - * - 目标目录的**直接子文件**里 .cbz/.zip 也成行(行=文件名+档内页数,has_images=1); - * - 档案册探测由调用方注入(mr_lib_archive_fn),logic 层不依赖 zip 容器; - * 不注入时所有 *_ex 入口与 M7 行为逐字一致; - * - 册间衔接的兄弟表从「含图目录」扩成「含图目录 ∪ 含图片页的档案册」,同列自然序。 - * 小函数边界(add_dir_entry / add_archive_entry / entry_subtitle / entry_badge / - * sibling_is_book)刻意留给 M13 的书库重写复用。 + * M11 档案册:注入 archive_pages 时 .cbz/.zip 直接子文件也成行(懒探测页数); + * 未注入时只列目录(M7 书库语义)。册间衔接的兄弟表 = 含图目录 ∪ 含图片页的档案册。 */ #include "library.h" #include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */ #include #include +#include #include #include #include #include #include -/* 系统目录不参与书库(SD 根上的 MeowOS 目录树)。 */ +#ifdef MR_LIBRARY_TEST_HOOKS +static mr_library_io_stats g_io; + +void mr_library_test_io_reset(void) +{ + memset(&g_io, 0, sizeof g_io); +} + +void mr_library_test_io_get(mr_library_io_stats *out) +{ + if (out) *out = g_io; +} + +#define IO_OPEN() (g_io.opendirs++) +#define IO_READDIR() (g_io.readdirs++) +#define IO_LSTAT() (g_io.lstats++) +#else +#define IO_OPEN() ((void)0) +#define IO_READDIR() ((void)0) +#define IO_LSTAT() ((void)0) +#endif + +/* 系统目录不参与**册间衔接**(M7 兄弟册表);浏览本身已放开(不隐藏)。 */ static int ignored_dir_name(const char *name) { static const char *const skip[] = { "MeowAPPs", "MeowData", "MeowOS", "lost+found" }; @@ -119,16 +138,7 @@ int mr_natural_cmp(const char *a, const char *b) return strcmp(a, b) < 0 ? -1 : (strcmp(a, b) > 0 ? 1 : 0); } -typedef struct scan_info { - int pages; /* 直接图片数 */ - int subdirs; /* 含图子目录数 */ - int archives; /* 直接档案册数(.cbz/.zip 且探测到图片页) */ - int nav; /* 1=本目录最终含书(可直接看或可导航到书) */ - char cover[MR_PATH_MAX]; /* 缩略图源 */ - char first_name[MR_NAME_MAX]; /* 本目录自然序第一张图片的文件名(比较用) */ -} scan_info; - -/* 档案册探测(UI 注入;NULL=纯目录书库,行为与 M7 一致)。 */ +/* 档案册探测(UI 注入;NULL=纯目录书库,档案文件不成行)。 */ static int archive_pages_at(mr_lib_archive_fn probe, void *ud, const char *path) { if (!probe || !path || !path[0]) return 0; @@ -136,48 +146,234 @@ static int archive_pages_at(mr_lib_archive_fn probe, void *ud, const char *path) return n > 0 ? n : 0; } -static void scan_dir(const char *path, int depth, int max_depth, scan_info *si, - mr_lib_archive_fn probe, void *ud) +/* ============================================================ */ +/* 单层探测 + mtime/size 失效缓存 */ +/* ============================================================ */ +int mr_library_probe_dir(const char *dir, mr_library_probe *out) { - memset(si, 0, sizeof *si); - DIR *d = opendir(path); - if (!d) return; + if (!out) return -1; + memset(out, 0, sizeof *out); + if (!dir || !dir[0]) return -1; + IO_OPEN(); + DIR *d = opendir(dir); + if (!d) return -1; + + char best[MR_NAME_MAX]; + best[0] = '\0'; + out->empty = 1; struct dirent *de; while ((de = readdir(d)) != NULL) { + IO_READDIR(); const char *name = de->d_name; - if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; - if (ignored_dir_name(name)) continue; + if (name[0] == '.') continue; /* 隐藏项不算内容 */ + out->empty = 0; + if (!mr_path_is_image(name)) continue; char full[MR_PATH_MAX]; - if (join_path(full, (int)sizeof full, path, name) != 0) continue; - + if (join_path(full, (int)sizeof full, dir, name) != 0) continue; + IO_LSTAT(); struct stat st; - if (lstat(full, &st) != 0) continue; - if (S_ISLNK(st.st_mode)) continue; + if (lstat(full, &st) != 0 || !S_ISREG(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, probe, ud); - 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)) { - if (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); - } - } else if (mr_path_is_archive(name)) { - if (archive_pages_at(probe, ud, full) > 0) si->archives++; - } + out->pages++; + if (best[0] == '\0' || mr_natural_cmp(name, best) < 0) { + copy_str(best, (int)sizeof best, name); + copy_str(out->cover, (int)sizeof out->cover, full); } } closedir(d); - si->nav = (si->pages > 0 || si->subdirs > 0 || si->archives > 0); + return 0; +} + +#define MR_PROBE_CACHE_MAX 256 + +typedef struct probe_cache_entry { + char path[MR_PATH_MAX]; + long long mtime_ns; + long long size; + mr_library_probe probe; + unsigned long long last_use; +} probe_cache_entry; + +static probe_cache_entry *g_cache[MR_PROBE_CACHE_MAX]; +static unsigned long long g_cache_clock; +static pthread_mutex_t g_cache_lock = PTHREAD_MUTEX_INITIALIZER; + +static long long stat_mtime_ns(const struct stat *st) +{ +#if defined(__APPLE__) + return (long long)st->st_mtimespec.tv_sec * 1000000000LL + st->st_mtimespec.tv_nsec; +#else + return (long long)st->st_mtim.tv_sec * 1000000000LL + st->st_mtim.tv_nsec; +#endif +} + +static probe_cache_entry *cache_find_locked(const char *path) +{ + for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) { + if (!g_cache[i]) continue; + if (strcmp(g_cache[i]->path, path) == 0) return g_cache[i]; + } + return NULL; +} + +static void cache_put_locked(const char *path, long long mtime_ns, long long size, + const mr_library_probe *p) +{ + probe_cache_entry *e = cache_find_locked(path); + if (!e) { + int slot = -1; + for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) + if (!g_cache[i]) { slot = i; break; } + if (slot < 0) { /* 满:淘汰最久未用项 */ + int lru = 0; + unsigned long long oldest = g_cache[0]->last_use; + for (int i = 1; i < MR_PROBE_CACHE_MAX; i++) { + if (g_cache[i]->last_use < oldest) { + oldest = g_cache[i]->last_use; + lru = i; + } + } + free(g_cache[lru]); + g_cache[lru] = NULL; + slot = lru; + } + e = (probe_cache_entry *)malloc(sizeof *e); + if (!e) return; /* 缓存只是加速:失败就每次现算 */ + if (copy_str(e->path, (int)sizeof e->path, path) != 0) { + free(e); + return; + } + g_cache[slot] = e; + } + e->mtime_ns = mtime_ns; + e->size = size; + e->probe = *p; + e->last_use = ++g_cache_clock; +} + +void mr_library_probe_cache_clear(void) +{ + pthread_mutex_lock(&g_cache_lock); + for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) { + free(g_cache[i]); + g_cache[i] = NULL; + } + g_cache_clock = 0; + pthread_mutex_unlock(&g_cache_lock); +} + +/* 无缓存探测:目录=单层目录扫描;档案册=注入回调取页数。 */ +static int probe_path_uncached(const char *path, int is_archive, + mr_lib_archive_fn archive_pages, void *ud, + mr_library_probe *out) +{ + struct stat st; + if (stat(path, &st) != 0) return -1; + if (is_archive) { + if (!archive_pages || !S_ISREG(st.st_mode)) return -1; + out->pages = archive_pages_at(archive_pages, ud, path); + out->empty = 0; + out->cover[0] = '\0'; + return 0; + } + if (!S_ISDIR(st.st_mode)) return -1; + return mr_library_probe_dir(path, out); +} + +int mr_library_probe_path(const char *path, int is_archive, + mr_lib_archive_fn archive_pages, void *ud, + mr_library_probe *out) +{ + if (!out) return -1; + memset(out, 0, sizeof *out); + if (!path || !path[0]) return -1; + + struct stat st; + if (stat(path, &st) != 0) return -1; + if (is_archive ? !S_ISREG(st.st_mode) : !S_ISDIR(st.st_mode)) return -1; + + long long mtime_ns = stat_mtime_ns(&st); + long long size = (long long)st.st_size; + + pthread_mutex_lock(&g_cache_lock); + probe_cache_entry *hit = cache_find_locked(path); + if (hit && hit->mtime_ns == mtime_ns && hit->size == size) { + *out = hit->probe; + hit->last_use = ++g_cache_clock; + pthread_mutex_unlock(&g_cache_lock); + return 0; + } + pthread_mutex_unlock(&g_cache_lock); + + mr_library_probe p; + memset(&p, 0, sizeof p); + if (probe_path_uncached(path, is_archive, archive_pages, ud, &p) != 0) return -1; + + /* 评审 P2:只把「探测前后完全一致」的结果写入缓存。 + * 探测期间目录/档案的 mtime 或 size 变了(新增/替换文件),说明结果可能不完整, + * 不能绑定到新 mtime 上当「新鲜」——本次直接返回,下次调用重新探测。 */ + struct stat st2; + if (stat(path, &st2) == 0 && stat_mtime_ns(&st2) == mtime_ns && + (long long)st2.st_size == size) { + pthread_mutex_lock(&g_cache_lock); + cache_put_locked(path, mtime_ns, size, &p); + pthread_mutex_unlock(&g_cache_lock); + } + + *out = p; + return 0; +} + +int mr_library_probe_cached(const char *dir, mr_library_probe *out) +{ + return mr_library_probe_path(dir, 0, NULL, NULL, out); +} + +/* ============================================================ */ +/* 行状态:副标题 / 补位 / 行合成 */ +/* ============================================================ */ +/* 基础副标题(不含 M7 续读角标)。 */ +static void entry_subtitle(mr_library_entry *e) +{ + switch (e->probe_state) { + case MR_LIB_READY: + if (e->pages > 0) + snprintf(e->subtitle, sizeof e->subtitle, "%d page%s", e->pages, + e->pages == 1 ? "" : "s"); + else if (!e->is_archive && e->empty) + snprintf(e->subtitle, sizeof e->subtitle, "空目录"); + else + snprintf(e->subtitle, sizeof e->subtitle, "无图片"); + break; + case MR_LIB_FAILED: + snprintf(e->subtitle, sizeof e->subtitle, "无法读取"); + break; + default: + snprintf(e->subtitle, sizeof e->subtitle, "…"); + break; + } +} + +/* 续读角标(M7):有 last page 记录的书行,在副标题后缀「· 续读 Pn」。 + * 档案册以档案路径为册标识,同一套逻辑复用。 + * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ +static void entry_badge(mr_library_entry *e) +{ + if (e->has_images != 1) return; + char badge[24]; + if (!mr_cont_badge(e->path, badge, (int)sizeof badge)) return; + int used = (int)strlen(e->subtitle); + if (used < (int)sizeof e->subtitle - 2) + snprintf(e->subtitle + used, sizeof e->subtitle - (size_t)used, " · %s", badge); +} + +void mr_library_format_subtitle(mr_library_entry *e) +{ + if (!e) return; + entry_subtitle(e); + entry_badge(e); } void mr_library_free(mr_library *lib) @@ -187,6 +383,7 @@ void mr_library_free(mr_library *lib) lib->items = NULL; lib->count = 0; lib->cap = 0; + lib->truncated = 0; } static int lib_add(mr_library *lib, const mr_library_entry *e) @@ -210,51 +407,22 @@ static int entry_cmp(const void *pa, const void *pb) return mr_natural_cmp(a->name, b->name); } -/* 副标题(M11 扩展):直接含图 → pages;只有档案册 → books;否则 folders。 */ -static void entry_subtitle(mr_library_entry *e, int archive_books) -{ - if (e->has_images) - snprintf(e->subtitle, sizeof e->subtitle, "%d page%s", e->pages, - e->pages == 1 ? "" : "s"); - else if (archive_books > 0) - snprintf(e->subtitle, sizeof e->subtitle, "%d book%s", archive_books, - archive_books == 1 ? "" : "s"); - else - snprintf(e->subtitle, sizeof e->subtitle, "%d folder%s", e->subdirs, - e->subdirs == 1 ? "" : "s"); -} - -/* 续读角标(M7):有 last page 记录的册,在副标题后缀「· 续读 Pn」。 - * 档案册以档案路径为册标识,同一套逻辑复用。 - * config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */ -static void entry_badge(mr_library_entry *e) -{ - if (!e->has_images) return; - char badge[24]; - if (!mr_cont_badge(e->path, badge, (int)sizeof badge)) return; - int used = (int)strlen(e->subtitle); - if (used < (int)sizeof e->subtitle - 2) - snprintf(e->subtitle + used, sizeof e->subtitle - (size_t)used, " · %s", badge); -} - -/* 文件夹册(含可导航中间目录)一行。 */ -static int add_dir_entry(mr_library *lib, const char *name, const char *full, - const scan_info *si) +/* 文件夹册行(M13):一律列出、初始待探测;是否「书」由后台 probe 决定。 */ +static int add_dir_entry(mr_library *lib, const char *name, const char *full) { mr_library_entry e; memset(&e, 0, sizeof e); if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; - copy_str(e.cover, (int)sizeof e.cover, si->cover); - e.pages = si->pages; - e.subdirs = si->subdirs; - e.has_images = (si->pages > 0); - entry_subtitle(&e, si->archives); - entry_badge(&e); + e.pages = -1; + e.has_images = -1; + e.probe_state = MR_LIB_PENDING; + mr_library_format_subtitle(&e); return lib_add(lib, &e); } -/* 档案册(.cbz/.zip)一行:文件即一本书,点入直接阅读。 +/* 档案册(.cbz/.zip)行:文件即一本书,点入直接阅读。 + * pages<0=待后台探测(M13 懒算);>=0=已知页数(扫描期注入的旧路径/测试用)。 * cover 留空——缩略图由 UI 解压档内首图(mr_zip_cover_path)生成。 */ static int add_archive_entry(mr_library *lib, const char *name, const char *full, int pages) @@ -263,52 +431,58 @@ static int add_archive_entry(mr_library *lib, const char *name, const char *full memset(&e, 0, sizeof e); if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0; if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0; - e.pages = pages; - e.subdirs = 0; - e.has_images = 1; - snprintf(e.subtitle, sizeof e.subtitle, "%d page%s", pages, pages == 1 ? "" : "s"); - entry_badge(&e); + e.is_archive = 1; + if (pages < 0) { + e.pages = -1; + e.has_images = -1; + e.probe_state = MR_LIB_PENDING; + } else { + e.pages = pages; + e.has_images = (pages > 0) ? 1 : 0; + e.probe_state = MR_LIB_READY; + } + mr_library_format_subtitle(&e); return lib_add(lib, &e); } +/* ============================================================ */ +/* 单层扫描 */ +/* ============================================================ */ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, mr_lib_archive_fn archive_pages, void *ud) { + (void)max_depth; /* 兼容旧签名:扫描不再递归 */ + (void)ud; 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; - + IO_OPEN(); DIR *d = opendir(dir); if (!d) return -1; struct dirent *de; while ((de = readdir(d)) != NULL) { + IO_READDIR(); const char *name = de->d_name; - if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; - if (ignored_dir_name(name)) continue; + if (name[0] == '.') continue; /* . .. 与隐藏项 */ char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, dir, name) != 0) continue; + IO_LSTAT(); struct stat st; if (lstat(full, &st) != 0) continue; if (S_ISLNK(st.st_mode)) continue; - int rc = 0; - if (S_ISDIR(st.st_mode)) { - scan_info si; - scan_dir(full, 1, max_depth, &si, archive_pages, ud); - if (!si.nav) continue; - rc = add_dir_entry(lib, name, full, &si); - } else if (S_ISREG(st.st_mode) && mr_path_is_archive(name)) { - int pages = archive_pages_at(archive_pages, ud, full); - if (pages <= 0) continue; /* 坏档/无图片页:不当书 */ - rc = add_archive_entry(lib, name, full, pages); - } else { - continue; - } + int is_dir = S_ISDIR(st.st_mode); + int is_arch = (!is_dir && archive_pages && S_ISREG(st.st_mode) && + mr_path_is_archive(name)); + if (!is_dir && !is_arch) continue; + if (lib->count >= MR_LIB_SCAN_MAX) { lib->truncated = 1; break; } + + int rc = is_dir ? add_dir_entry(lib, name, full) + : add_archive_entry(lib, name, full, -1); if (rc != 0) { closedir(d); mr_library_free(lib); @@ -317,7 +491,8 @@ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, } closedir(d); - if (lib->count > 1) qsort(lib->items, (size_t)lib->count, sizeof *lib->items, entry_cmp); + if (lib->count > 1) + qsort(lib->items, (size_t)lib->count, sizeof *lib->items, entry_cmp); return lib->count; } @@ -326,6 +501,62 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth) return mr_library_scan_ex(lib, dir, max_depth, NULL, NULL); } +int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p) +{ + if (!lib || !p || idx < 0 || idx >= lib->count) return 0; + mr_library_entry *e = &lib->items[idx]; + e->pages = p->pages; + e->empty = p->empty; + e->has_images = (p->pages > 0) ? 1 : 0; + e->probe_state = MR_LIB_READY; + copy_str(e->cover, (int)sizeof e->cover, p->cover); + mr_library_format_subtitle(e); + return 1; +} + +int mr_library_mark_error(mr_library *lib, int idx) +{ + if (!lib || idx < 0 || idx >= lib->count) return 0; + mr_library_entry *e = &lib->items[idx]; + e->pages = -1; + e->empty = 0; + e->has_images = 0; + e->probe_state = MR_LIB_FAILED; + e->cover[0] = '\0'; + mr_library_format_subtitle(e); + return 1; +} + +int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out) +{ + static char trunc_sub[48]; + static int trunc_ready; + + if (!lib || !out) return 0; + if (idx == lib->count && lib->truncated) { + if (!trunc_ready) { + snprintf(trunc_sub, sizeof trunc_sub, "仅显示前 %d 项", MR_LIB_SCAN_MAX); + trunc_ready = 1; + } + memset(out, 0, sizeof *out); + out->title = "已截断"; + out->subtitle = trunc_sub; + out->disabled = 1; + out->index = -1; + return 1; + } + if (idx < 0 || idx >= lib->count) return 0; + + const mr_library_entry *e = &lib->items[idx]; + memset(out, 0, sizeof *out); + out->title = e->name; + out->subtitle = e->subtitle; + out->is_book = (e->has_images == 1); + out->is_archive = e->is_archive; + out->index = idx; + return 1; +} + int mr_path_parent(const char *path, char *out, int outsz) { if (!path || !out || outsz <= 0) return -1; @@ -346,7 +577,7 @@ int mr_path_parent(const char *path, char *out, int outsz) } /* ============================================================ */ -/* 册间衔接(M7) */ +/* 册间衔接(M7 / M11) */ /* ============================================================ */ int mr_library_dir_has_images(const char *dir) { @@ -427,7 +658,8 @@ int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, char full[MR_PATH_MAX]; if (join_path(full, (int)sizeof full, parent, name) != 0) continue; struct stat st; - if (lstat(full, &st) != 0 || S_ISLNK(st.st_mode)) continue; + if (lstat(full, &st) != 0) continue; + if (S_ISLNK(st.st_mode)) continue; if (!sibling_is_book(full, &st, archive_pages, ud)) continue; if (n >= cap) { diff --git a/tuku/logic/library.h b/tuku/logic/library.h index ba57c82..4149c88 100644 --- a/tuku/logic/library.h +++ b/tuku/logic/library.h @@ -1,19 +1,25 @@ -/* logic/library.h — 漫画书库扫描/排序(纯 C,不依赖 meow_ui.h)。 +/* logic/library.h — 书库目录浏览(纯 C,不依赖 meow_ui.h)。 * * 分层契约同 examples/hello:logic 层只含文件系统与纯数据,宿主 gcc 可直接裸测。 - * (M7 起另提供每册续读角标与册间顺序查询;续读状态本身由 logic/reader.c 持久化, - * 本层只读它来拼副标题,宿主测试用配置存根顶替。) - * 书库语义: - * - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」;M11 起另列直接子文件里的 - * .cbz/.zip(档案册即一本书,与文件夹册同列自然序混排); - * - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含书的中间目录(可继续导航); - * - 行的 pages=该目录直接图片数(档案册=档内图片页数);subdirs=其下含图子目录数; - * - cover=缩略图源文件:优先该目录自然序第一张图,否则取第一个含图子目录的 cover - * (档案册留空,UI 解压档内首图); - * - 条目按名称自然排序(page2 < page10),含前导零与数字/字母混合名。 * - * 深度上限 MR_LIB_MAX_DEPTH 用于防止坏卡/超大树的无限递归;隐藏目录与系统目录 - * (. 开头、MeowAPPs/MeowData/MeowOS/lost+found)不参与书库。符号链接跳过(防环)。 + * M13 浏览语义(用户要求:性能 + 访问放开;参考 os.meow.files 的 fsx_scan_dir): + * - mr_library_scan / mr_library_scan_ex 只枚举**当前目录一层**:一次 opendir/readdir + + * 逐项 lstat,列出全部子目录(自然升序),不做任何递归「子树含图」探测; + * - 访问放开:系统目录(MeowAPPs/MeowData/MeowOS/lost+found)不再隐藏, + * 空目录/无图目录一律列出并可进入;仅跳过 "."/".." 与其它 "." 开头的隐藏项、 + * 符号链接(防环); + * - 「直接含图目录=一本书」的判定改为**后台懒算**:mr_library_probe_dir 只读一层, + * mr_library_probe_path 按行类型(目录/档案册)走同一套 mtime 失效缓存(线程安全), + * 结果经 mr_library_apply_probe 补回行(页数/首图/副标题)。首帧/滚动不做子目录 IO; + * - 单目录条目上限 MR_LIB_SCAN_MAX(默认 2000):到上限后若还有条目则截断并置 + * lib->truncated=1(UI 用 mr_library_row_at 末尾的提示行标注)。 + * + * M11 档案册(.cbz/.zip):注入 archive_pages 回调时,当前层的档案文件也成行(与目录同列 + * 自然序混排);档案册的页数同样走懒探测(mr_library_probe_path + 注入回调),logic 层 + * 不依赖 zip 容器。未注入时(archive_pages==NULL)只列目录,等价 M7 书库。 + * + * M7 续读角标 / 册间衔接:mr_library_format_subtitle 在探测完成后拼「· 续读 Pn」 + * (mr_cont_badge);mr_library_dir_has_images / mr_book_adjacent(_ex) 语义不变。 */ #ifndef MR_LIBRARY_H #define MR_LIBRARY_H @@ -24,24 +30,55 @@ extern "C" { #define MR_PATH_MAX 512 #define MR_NAME_MAX 256 -#define MR_LIB_MAX_DEPTH 8 +#define MR_LIB_MAX_DEPTH 8 /* 兼容旧调用:扫描已不递归,该参数被忽略 */ + +/* 单目录列出的条目上限(超大目录截断 + UI 提示;可用 -D 覆写便于宿主测试)。 */ +#ifndef MR_LIB_SCAN_MAX +#define MR_LIB_SCAN_MAX 2000 +#endif + +/* 行懒判定状态:0=待探测(副标题占位「…」)/1=已探测/2=探测失败(不可读)。 */ +#define MR_LIB_PENDING 0 +#define MR_LIB_READY 1 +#define MR_LIB_FAILED 2 typedef struct mr_library_entry { char name[MR_NAME_MAX]; /* 目录名/档案文件名(行主标题) */ - char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader 用) */ - char cover[MR_PATH_MAX]; /* 缩略图源图(空=none;档案册由 UI 解压首图) */ - char subtitle[48]; /* 预先格式化好的 "N pages" / "N books" / "N folders" */ - int pages; /* 本目录直接图片数(档案册=档内图片页数) */ - int subdirs; /* 本目录下含图子目录数 */ - int has_images; /* 1=可直接阅读(直接含图 或 档案册) */ + char path[MR_PATH_MAX]; /* 完整路径(导航/进 reader/探测用) */ + char cover[MR_PATH_MAX]; /* 后台探测填:自然序第一张直接子图(档案册留空,UI 解压首图) */ + char subtitle[48]; /* 占位「…」;探测后 "N pages"/"空目录"/"无图片"/"无法读取" */ + int pages; /* -1=未判定;>=0=直接图片数(档案册=档内图片页数) */ + int subdirs; /* 兼容字段(M11 及旧读者):新扫描不统计,恒 0 */ + int has_images; /* 1=书;0=非书;-1=未判定 */ + int probe_state; /* MR_LIB_PENDING / MR_LIB_READY / MR_LIB_FAILED */ + int empty; /* 1=空目录(目录且 probe_state==READY 后有效) */ + int is_archive; /* 1=.cbz/.zip 档案册行 */ } mr_library_entry; typedef struct mr_library { mr_library_entry *items; - int count; + int count; /* 当前层列出的条目数 */ int cap; + int truncated; /* 1=达到 MR_LIB_SCAN_MAX 被截断 */ } mr_library; +/* 单行探测结果(目录=本层直接图片数;档案册=档内图片页数;均不递归)。 */ +typedef struct mr_library_probe { + int pages; /* 受支持图片数(png/jpg/jpeg/bmp;档案册=档内页数) */ + int empty; /* 1=目录没有可见子项(档案册恒 0) */ + char cover[MR_PATH_MAX]; /* 目录的自然序第一张图(档案册留空) */ +} mr_library_probe; + +/* 行数据(纯内存合成;UI 的每帧 src 回调直接取用,零 IO)。 */ +typedef struct mr_library_row { + const char *title; /* 主标题 */ + const char *subtitle; /* 副标题(可空串) */ + int is_book; /* 1=书(显示页数/缩略图,点入阅读,无进入箭头) */ + int is_archive; /* 1=档案册行 */ + int disabled; /* 1=提示行(不可点,如截断标注) */ + int index; /* 对应 mr_library.items 下标;提示行=-1 */ +} mr_library_row; + /* 扩展名判断:png/jpg/jpeg/bmp(大小写不敏感)。1=受支持图片。 */ int mr_path_is_image(const char *name); @@ -53,22 +90,19 @@ int mr_path_is_archive(const char *name); * 非数字段 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 时按上限处理。 */ +/* 扫描 dir 的**直接子目录**(单层;不读子目录内容),写入 lib(先释放 lib 旧内容)。 + * 返回条目数(>=0);dir 不可读/不存在/不是目录返回 -1(lib 仍为空且可安全 free)。 + * max_depth 仅为兼容旧调用保留,已忽略(不再递归)。 */ int mr_library_scan(mr_library *lib, const char *dir, int max_depth); /* ============================================================ */ /* 档案册(M11):.cbz/.zip 当一本书,与文件夹册同列 */ /* ------------------------------------------------------------ * logic 层不依赖 zip 容器(保持宿主裸测/链接面干净):档案册探测由 UI 注入。 - * archive_pages(path, ud) 对普通文件返回图片页数(>0=是一本书),0=不是/坏档。 - * 传 NULL 时行为与 mr_library_scan 完全一致(纯目录书库),M7 语义不变。 - * - * 档案册行的口径:name=文件名(含扩展名)、path=档案文件路径、pages=档内页数、 - * has_images=1(点入直接阅读)、cover 留空(缩略图由 UI 解压首图生成)、 - * 副标题 "N pages"、续读角标同文件夹册(按档案路径记)。 - * 只含档案册的子目录算「可导航」(nav),副标题写 "N books"。 + * archive_pages(path, ud) 返回档内图片页数(>0=是一本书),0=不是/坏档。 + * 传 NULL 时行为与 M7 完全一致(纯目录书库,档案文件不成行)。 + * 档案册行:name=文件名、path=档案路径、is_archive=1、cover 留空(UI 解压首图)、 + * 页数/书标记由后台 probe 经同一注入回调懒算。 * ============================================================ */ typedef int (*mr_lib_archive_fn)(const char *path, void *ud); @@ -79,14 +113,52 @@ int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth, void mr_library_free(mr_library *lib); /* 取父目录路径(去掉末尾 '/' 后截断最后一段)。根 "/" 返回 "/"。 - * 成功 0;参数非法/输出缓冲不足 -1。 */ + * 成功 0;参数非法/输出缓冲不足 -1。可一路向上到文件系统 '/'。 */ int mr_path_parent(const char *path, char *out, int outsz); +/* ---- 后台懒判定(单层探测;可由工作线程调用) ---- */ + +/* 单层探测目录 dir:直接图片数 + 自然序首图 + 是否空目录。不递归、不跟随符号链接。 + * 0=成功;-1=参数非法/不可读(out 清零)。 */ +int mr_library_probe_dir(const char *dir, mr_library_probe *out); + +/* 带缓存探测目录:按目录 mtime(秒+纳秒)+ size 失效;内部互斥锁,可多线程并发调用。 + * 0=成功(可能来自缓存);-1=不可读(失败不缓存)。 */ +int mr_library_probe_cached(const char *dir, mr_library_probe *out); + +/* 按行探测(目录或档案册),带同一套缓存: + * is_archive=0 → 目录(等价 mr_library_probe_cached); + * is_archive=1 → 调 archive_pages(path,ud) 取档内页数(cover 留空、empty=0); + * archive_pages 为 NULL 时返回 -1。 + * 0=成功;-1=参数非法/不可读。探测期间 mtime/size 变化则不写入缓存(避免假新鲜)。 */ +int mr_library_probe_path(const char *path, int is_archive, + mr_lib_archive_fn archive_pages, void *ud, + mr_library_probe *out); + +/* 清空探测缓存(目录整体切换/测试用)。 */ +void mr_library_probe_cache_clear(void); + +/* 把探测结果补回第 idx 行(pages/has_images/cover/subtitle/empty)。 + * 1=已应用;0=参数非法/越界。 */ +int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p); + +/* 标记第 idx 行探测失败(不可读):副标题「无法读取」,视为非书。1=已应用;0=非法。 */ +int mr_library_mark_error(mr_library *lib, int idx); + +/* 按行当前状态重算副标题(占位/页数/空目录/无图片/无法读取), + * 并在书行(has_images==1)后缀「· 续读 Pn」(M7 角标)。 */ +void mr_library_format_subtitle(mr_library_entry *e); + +/* 行合成(纯内存、零 IO、O(1);UI 线程每帧调用):idx 越界返回 0。 + * idx==count 且 lib->truncated 时输出末尾提示行(disabled=1)。 + * 返回的 title/subtitle 指向 lib 内部或函数内静态缓冲(截断提示行),调用方不要长期持有。 */ +int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out); + /* 目录是否**直接**含受支持图片(= 书库语义里的「一本书」,可直接进 reader)。 * 1=有;0=没有/不可读。 */ int mr_library_dir_has_images(const char *dir); -/* 册间衔接(M7):同父目录下按书库自然序排列的「可直接阅读」兄弟册。 +/* 册间衔接(M7):同父目录下按书库自然序排列的「直接含图」兄弟册。 * delta=+1 下一册 / -1 上一册。1=找到并写入 out 完整路径; * 0=没有相邻册(当前册是首/末册,或当前册不在兄弟表中);-1=参数非法。 * 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 @@ -94,12 +166,23 @@ int mr_library_dir_has_images(const char *dir); int mr_book_adjacent(const char *dir, int delta, char *out, int outsz); /* 册间衔接 + 档案册(M11):兄弟表 = 「直接含图目录」∪「含图片页的 .cbz/.zip」, - * 两者同列按名称自然序混排(mission 基线:跨册衔接按同父目录自然序)。 - * 因此末页前进可以从文件夹册进到同目录的档案册,反之亦然。 + * 两者同列按名称自然序混排(跨册衔接按同父目录自然序)。 * archive_pages 传 NULL 时与 mr_book_adjacent 完全等价。 */ int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz, mr_lib_archive_fn archive_pages, void *ud); +#ifdef MR_LIBRARY_TEST_HOOKS +/* 宿主测试用 IO 计数(scan/probe_dir 的 opendir/readdir/lstat 次数;线程安全不保证)。 */ +typedef struct mr_library_io_stats { + long opendirs; + long readdirs; + long lstats; +} mr_library_io_stats; + +void mr_library_test_io_reset(void); +void mr_library_test_io_get(mr_library_io_stats *out); +#endif + #ifdef __cplusplus } #endif diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 91a0e78..4d75e14 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -1,6 +1,9 @@ #!/bin/bash # run_tests.sh — 宿主 gcc 构建并运行图库测试(不链 SDK 静态库、不需要设备)。 -# - test_library:书库扫描/自然排序/阅读方向(46 项) +# - test_library:单层目录扫描/自然排序/父目录/阅读方向 +# - test_browse_nav:访问放开(系统/空/无图目录可进入)、上溯到 /、不可读目录、截断 +# - test_browse_lazy:单层探测/懒判定补位/mtime 失效缓存/行合成 O(1)/M7 册间语义 +# - test_browse_perf:500 目录×8 层嵌套+2000 文件夹具,断言扫描单层且有界 # - test_settings_reader_persist:阅读方向 meow_config 持久化(默认/落盘/重启恢复/ # 坏值/非法值/只读一次/写失败只告警) # - test_pin:启动 PIN 格式/设置/校验/修改/禁用/残缺配置/落盘失败(logic/pin.c 为纯 C, @@ -10,30 +13,48 @@ # 交付环境只有库+头时自动 SKIP。 # reader.c 的方向持久化只 include 配置/日志两个模块头;宿主测试用 test/settings_reader_stub.c # 顶替 meow_config_* 与 meow_log(所以下面各条都带 -I"$SDK" 与该存根)。 +# logic/library.c 的探测缓存用 pthread 互斥锁 → 相关编译带 -pthread。 set -e cd "$(dirname "$0")/.." SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" +OUT=build/host +mkdir -p "$OUT" gcc -Wall -Wextra -O1 -I. -I"$SDK" \ test/test_library.c logic/library.c logic/reader.c test/settings_reader_stub.c \ - -o test/test_library -./test/test_library + -pthread -o "$OUT/test_library" +"$OUT"/test_library + +gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \ + test/test_browse_nav.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + -pthread -o "$OUT/test_browse_nav" +"$OUT"/test_browse_nav + +gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \ + test/test_browse_lazy.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + -pthread -o "$OUT/test_browse_lazy" +"$OUT"/test_browse_lazy + +gcc -Wall -Wextra -O1 -DMR_LIBRARY_TEST_HOOKS -I. -I"$SDK" \ + test/test_browse_perf.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + -pthread -o "$OUT/test_browse_perf" +"$OUT"/test_browse_perf gcc -Wall -Wextra -O1 -I. -I"$SDK" \ test/test_reader_core.c logic/library.c logic/reader.c \ logic/reader/page_source.c logic/reader/nav.c test/settings_reader_stub.c \ - -o test/test_reader_core -./test/test_reader_core + -pthread -o "$OUT/test_reader_core" +"$OUT"/test_reader_core gcc -Wall -Wextra -O1 -DMR_READER_TEST_HOOKS -I. -I"$SDK" \ test/test_settings_reader_persist.c logic/reader.c test/settings_reader_stub.c \ - -o test/test_settings_reader_persist -./test/test_settings_reader_persist + -o "$OUT"/test_settings_reader_persist +"$OUT"/test_settings_reader_persist gcc -Wall -Wextra -O1 -I. -I"$SDK" \ test/test_pin.c logic/pin.c \ - -o test/test_pin -./test/test_pin + -o "$OUT"/test_pin +"$OUT"/test_pin if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -I. -I"$SDK" -I"$SDK/include" \ @@ -47,10 +68,27 @@ if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ - -pthread -lm -o test/test_settings_lock_host - ./test/test_settings_lock_host + -pthread -lm -o "$OUT"/test_settings_lock_host + "$OUT"/test_settings_lock_host + + # 书库浏览页集成(src2 懒算补位/行语义/上溯至 / /不可读提示);同样需要 SDK 源码树。 + gcc -Wall -Wextra -O1 -DMEOW_UI_HOST_TEST -DMR_LIBRARY_UI_TEST_HOOKS \ + -I. -I"$SDK" -I"$SDK/include" \ + test/test_browse_ui_host.c ui/library/library.c logic/library.c \ + logic/reader.c logic/reader/page_source.c logic/reader/archive_source.c \ + logic/reader/page_cache.c logic/zip/zip.c \ + "$SDK"/meow_ui_gfx.c "$SDK"/meow_ui_sys.c "$SDK"/meow_ui_theme.c \ + "$SDK"/meow_ui_vol.c "$SDK"/meow_ui_log.c "$SDK"/meow_ui_widget.c \ + "$SDK"/meow_ui_input.c "$SDK"/meow_ui_activity.c "$SDK"/meow_ui_dialog.c \ + "$SDK"/meow_ui_ime.c "$SDK"/meow_ui_str.c "$SDK"/meow_pinyin.c \ + "$SDK"/meow_ui_draw.c "$SDK"/meow_ui_view.c "$SDK"/meow_ui_box.c \ + "$SDK"/meow_ui_anim.c "$SDK"/meow_ui_scroll.c "$SDK"/meow_ui_img.c \ + "$SDK"/meow_ui_media.c "$SDK"/meow_notify_ipc.c "$SDK"/meow_ui_config.c \ + "$SDK"/meow_ui_nav.c "$SDK"/stb_image.c "$SDK"/test_host_stub.c \ + -pthread -lm -lz -o "$OUT"/test_browse_ui_host + "$OUT"/test_browse_ui_host else - echo "SKIP test_settings_lock_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" + echo "SKIP test_settings_lock_host/test_browse_ui_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" fi # ---- 档案册(M11):zip 容器 + CBZ 书库/阅读器集成 ---- diff --git a/tuku/test/test_browse_lazy.c b/tuku/test/test_browse_lazy.c new file mode 100644 index 0000000..b6843c8 --- /dev/null +++ b/tuku/test/test_browse_lazy.c @@ -0,0 +1,443 @@ +/* test_browse_lazy.c — 单层探测 / 懒判定 / mtime 失效缓存 / 行合成(纯 C,无 SDK)。 + * + * 覆盖: + * L1 单层探测:直接图片数、自然序首图、空目录标记;不递归(只有子目录含图的目录不算书); + * L2 懒判定接线:scan 后行全部 PENDING(占位副标题)→ apply_probe 后页数/首图/副标题/ + * 书标记;空目录「空目录」;无图目录「无图片」;探测失败「无法读取」且视为非书; + * L3 缓存:mtime 失效(同 mtime 命中不读盘;目录变化后重扫并刷新结果);并发调用线程安全; + * L3b 评审 P2:探测期间 mtime/size 变化的结果不写缓存(避免「不完整结果绑定新 mtime」); + * L4 行合成 O(1):mr_library_row_at 纯内存(IO 计数不变)、截断提示行; + * L5 M7 兼容语义:「直接含图兄弟册」+ 自然序册间顺序(M7 mr_book_adjacent 的口径)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ + * test/test_browse_lazy.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + * -pthread -o build/host/test_browse_lazy + */ +#include "logic/library.h" + +#include +#include +#include +#include +#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 void joinp(char *out, int outsz, const char *a, const char *b) +{ + snprintf(out, (size_t)outsz, "%s/%s", a, b); +} + +/* 把目录 mtime 显式推后 3 秒(避免同秒内变化被缓存误命中)。 */ +static void bump_mtime(const char *path) +{ + struct timespec ts[2]; + ts[0].tv_sec = time(NULL) + 3; + ts[0].tv_nsec = 0; + ts[1] = ts[0]; + utimensat(AT_FDCWD, path, ts, 0); +} + +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 int find_index(const mr_library *lib, const char *name, int *out) +{ + for (int i = 0; i < lib->count; i++) + if (strcmp(lib->items[i].name, name) == 0) { *out = i; return 1; } + *out = -1; + return 0; +} + +/* ---- L1 + L2 ---- */ +static void test_probe_and_apply(const char *root) +{ + char book[MR_PATH_MAX], noimg[MR_PATH_MAX], empty[MR_PATH_MAX], dirs_only[MR_PATH_MAX]; + char deep[MR_PATH_MAX]; + joinp(book, sizeof book, root, "book"); + joinp(noimg, sizeof noimg, root, "noimg"); + joinp(empty, sizeof empty, root, "empty"); + joinp(dirs_only, sizeof dirs_only, root, "dirs_only"); + joinp(deep, sizeof deep, root, "dirs_only/deep"); + + mr_library_probe p; + CHECK(mr_library_probe_dir(book, &p) == 0, "probe book"); + CHECK(p.pages == 2, "book pages=%d", p.pages); + CHECK(p.empty == 0, "book not empty"); + CHECK(strstr(p.cover, "page2.png") != NULL, "cover natural-first: %s", p.cover); + + CHECK(mr_library_probe_dir(noimg, &p) == 0 && p.pages == 0 && p.empty == 0, + "noimg: 0 pages, not empty"); + CHECK(mr_library_probe_dir(empty, &p) == 0 && p.pages == 0 && p.empty == 1, + "empty dir flagged"); + CHECK(mr_library_probe_dir(dirs_only, &p) == 0 && p.pages == 0 && p.empty == 0, + "dirs_only has no direct images (no recursion)"); + CHECK(mr_library_probe_dir(deep, &p) == 0 && p.pages == 1, "deep is a book"); + + char slash[MR_PATH_MAX]; + snprintf(slash, sizeof slash, "%s/", book); + CHECK(mr_library_probe_dir(slash, &p) == 0 && p.pages == 2, "trailing slash"); + + CHECK(mr_library_probe_dir("/no/such/dir", &p) == -1, "missing dir -> -1"); + CHECK(mr_library_probe_dir(NULL, &p) == -1, "NULL -> -1"); + + mr_library lib; + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) == 5, "scan 5 dirs (got %d)", lib.count); + + const mr_library_entry *e = find_entry(&lib, "book"); + CHECK(e && e->probe_state == MR_LIB_PENDING && e->pages == -1, + "book pending after scan"); + + int idx_book, idx_noimg, idx_empty, idx_dirs; + CHECK(find_index(&lib, "book", &idx_book), "book index"); + CHECK(find_index(&lib, "noimg", &idx_noimg), "noimg index"); + CHECK(find_index(&lib, "empty", &idx_empty), "empty index"); + CHECK(find_index(&lib, "dirs_only", &idx_dirs), "dirs_only index"); + if (idx_book < 0 || idx_noimg < 0 || idx_empty < 0 || idx_dirs < 0) { + mr_library_free(&lib); + return; + } + + CHECK(mr_library_probe_dir(lib.items[idx_book].path, &p) == 0, "probe row book"); + CHECK(mr_library_apply_probe(&lib, idx_book, &p) == 1, "apply book"); + e = &lib.items[idx_book]; + CHECK(e->has_images == 1 && e->pages == 2 && e->probe_state == MR_LIB_READY, + "book ready"); + CHECK(strcmp(e->subtitle, "2 pages") == 0, "book subtitle=%s", e->subtitle); + CHECK(strstr(e->cover, "page2.png") != NULL, "book row cover=%s", e->cover); + + CHECK(mr_library_probe_dir(lib.items[idx_noimg].path, &p) == 0 && + mr_library_apply_probe(&lib, idx_noimg, &p) == 1, "apply noimg"); + e = &lib.items[idx_noimg]; + CHECK(e->has_images == 0 && strcmp(e->subtitle, "无图片") == 0, + "noimg subtitle=%s", e->subtitle); + + CHECK(mr_library_probe_dir(lib.items[idx_empty].path, &p) == 0 && + mr_library_apply_probe(&lib, idx_empty, &p) == 1, "apply empty"); + e = &lib.items[idx_empty]; + CHECK(e->has_images == 0 && e->empty == 1 && strcmp(e->subtitle, "空目录") == 0, + "empty subtitle=%s", e->subtitle); + + CHECK(mr_library_mark_error(&lib, idx_dirs) == 1, "mark error"); + e = &lib.items[idx_dirs]; + CHECK(e->probe_state == MR_LIB_FAILED && e->has_images == 0 && + strcmp(e->subtitle, "无法读取") == 0, "failed subtitle=%s", e->subtitle); + + CHECK(mr_library_apply_probe(&lib, 999, &p) == 0, "apply out of range"); + CHECK(mr_library_mark_error(&lib, -1) == 0, "mark_error out of range"); + + mr_library_free(&lib); +} + +/* 档案册探测回调(P2 用例):可注入「探测期间文件变化」。 */ +typedef struct arch_probe_ctx { + int calls; + int bump_times; /* 前 N 次调用里改动文件 mtime(模拟探测期间变化) */ +} arch_probe_ctx; + +static int arch_probe(const char *path, void *ud) +{ + arch_probe_ctx *c = (arch_probe_ctx *)ud; + c->calls++; + if (c->calls <= c->bump_times) bump_mtime(path); + return 1; +} + +/* ---- L3b:评审 P2——探测期间 mtime 变化不得写入缓存(消除假新鲜) ---- */ +static void test_cache_race(const char *root) +{ + char arc[MR_PATH_MAX]; + joinp(arc, sizeof arc, root, "race.cbz"); + CHECK(touch_file(arc) == 0, "create race.cbz"); + + mr_library_probe_cache_clear(); + mr_library_probe p; + + /* 稳定文件:第二次走缓存,回调只调一次。 */ + arch_probe_ctx stable = { 0, 0 }; + CHECK(mr_library_probe_path(arc, 1, arch_probe, &stable, &p) == 0 && p.pages == 1, + "archive probe"); + CHECK(mr_library_probe_path(arc, 1, arch_probe, &stable, &p) == 0 && p.pages == 1, + "archive probe (cached)"); + CHECK(stable.calls == 1, "stable archive cached (calls=%d)", stable.calls); + + /* 探测期间文件变化:本次结果不得入缓存 → 下一次仍重新探测。 */ + mr_library_probe_cache_clear(); + arch_probe_ctx race = { 0, 1 }; + CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, + "racing archive probe"); + CHECK(race.calls == 1, "racing probe called once (calls=%d)", race.calls); + CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, + "racing archive probe again"); + CHECK(race.calls == 2, "changed-during-probe result NOT cached (calls=%d)", race.calls); + /* 第二次稳定后落缓存,第三次命中。 */ + CHECK(mr_library_probe_path(arc, 1, arch_probe, &race, &p) == 0 && p.pages == 1, + "racing archive probe third"); + CHECK(race.calls == 2, "stable result cached after third call (calls=%d)", race.calls); + mr_library_probe_cache_clear(); +} + +/* ---- L3:mtime 失效缓存 + 并发 ---- */ +#define THREADS 6 +typedef struct th_arg { + const char *dir; + int want; + int *fail; + pthread_mutex_t *mu; +} th_arg; + +static void *probe_thread(void *arg) +{ + th_arg *a = (th_arg *)arg; + for (int i = 0; i < 200; i++) { + mr_library_probe p; + int ok = (mr_library_probe_cached(a->dir, &p) == 0 && p.pages == a->want); + if (!ok) { + pthread_mutex_lock(a->mu); + (*a->fail)++; + pthread_mutex_unlock(a->mu); + } + } + return NULL; +} + +static void test_cache(const char *root) +{ + char book[MR_PATH_MAX]; + joinp(book, sizeof book, root, "book"); + + mr_library_probe_cache_clear(); + mr_library_io_stats st; + mr_library_probe p; + + mr_library_test_io_reset(); + CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 2, "cache miss probe"); + mr_library_test_io_get(&st); + CHECK(st.opendirs == 1, "cache miss opens once (got %ld)", st.opendirs); + + mr_library_test_io_reset(); + CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 2, "cache hit"); + mr_library_test_io_get(&st); + CHECK(st.opendirs == 0 && st.lstats == 0, "cache hit does no IO (open=%ld stat=%ld)", + st.opendirs, st.lstats); + + char img[MR_PATH_MAX]; + joinp(img, sizeof img, book, "page30.png"); + CHECK(touch_file(img) == 0, "add image"); + bump_mtime(book); + mr_library_test_io_reset(); + CHECK(mr_library_probe_cached(book, &p) == 0 && p.pages == 3, + "cache invalidated by mtime (pages=%d)", p.pages); + mr_library_test_io_get(&st); + CHECK(st.opendirs == 1, "invalidation re-opens (got %ld)", st.opendirs); + + CHECK(mr_library_probe_cached("/no/such/dir", &p) == -1, "missing dir -> -1"); + + pthread_t th[THREADS]; + th_arg args[THREADS]; + int failures = 0; + pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER; + int started = 0; + for (int t = 0; t < THREADS; t++) { + args[t].dir = book; + args[t].want = 3; + args[t].fail = &failures; + args[t].mu = μ + if (pthread_create(&th[t], NULL, probe_thread, &args[t]) == 0) started++; + } + for (int t = 0; t < started; t++) pthread_join(th[t], NULL); + CHECK(started == THREADS, "threads started (%d)", started); + CHECK(failures == 0, "concurrent probe failures=%d", failures); + pthread_mutex_destroy(&mu); +} + +/* ---- L4:行合成 O(1) + 截断提示行 ---- */ +static void test_rows(const char *root) +{ + mr_library lib; + memset(&lib, 0, sizeof lib); + mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + + mr_library_io_stats st; + mr_library_test_io_reset(); + struct timespec t0, t1; + clock_gettime(CLOCK_MONOTONIC, &t0); + int rows = 0; + for (int r = 0; r < 2000; r++) + for (int i = 0; i < lib.count; i++) { + mr_library_row row; + if (mr_library_row_at(&lib, i, &row)) rows++; + } + clock_gettime(CLOCK_MONOTONIC, &t1); + mr_library_test_io_get(&st); + long long us = (long long)(t1.tv_sec - t0.tv_sec) * 1000000LL + + (t1.tv_nsec - t0.tv_nsec) / 1000LL; + CHECK(rows == 2000 * lib.count, "rows synthesized (%d)", rows); + CHECK(st.opendirs == 0 && st.readdirs == 0 && st.lstats == 0, + "row synth is pure memory (open=%ld rd=%ld stat=%ld)", + st.opendirs, st.readdirs, st.lstats); + CHECK(us < 2000000, "row synth bounded (%lldus)", us); + + mr_library_row row; + CHECK(mr_library_row_at(&lib, lib.count, &row) == 0, "no extra row when not truncated"); + lib.truncated = 1; + CHECK(mr_library_row_at(&lib, lib.count, &row) == 1 && row.disabled == 1, + "truncation info row"); + CHECK(strcmp(row.title, "已截断") == 0, "truncation title=%s", row.title); + CHECK(strstr(row.subtitle, "2000") != NULL, "truncation subtitle=%s", row.subtitle); + mr_library_free(&lib); +} + +/* ---- L5:M7 兼容语义(直接含图兄弟册 + 自然序) ---- */ +static void collect_books(const char *parent, char names[][MR_NAME_MAX], int max, int *out_n) +{ + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = 0; + if (mr_library_scan(&lib, parent, MR_LIB_MAX_DEPTH) > 0) { + for (int i = 0; i < lib.count && n < max; i++) { + mr_library_probe p; + if (mr_library_probe_cached(lib.items[i].path, &p) == 0 && p.pages > 0) { + snprintf(names[n], MR_NAME_MAX, "%s", lib.items[i].name); + n++; + } + } + } + mr_library_free(&lib); + *out_n = n; +} + +static int book_index(char names[][MR_NAME_MAX], int n, const char *cur) +{ + for (int i = 0; i < n; i++) + if (strcmp(names[i], cur) == 0) return i; + return -1; +} + +static void test_m7_semantics(const char *root) +{ + char parent[MR_PATH_MAX]; + joinp(parent, sizeof parent, root, "series"); + + char names[16][MR_NAME_MAX]; + int n = 0; + collect_books(parent, names, 16, &n); + CHECK(n == 3, "series books=%d (vol1/vol2/vol10; vol6 is not a book)", n); + if (n == 3) { + CHECK(strcmp(names[0], "vol1") == 0, "book[0]=%s", names[0]); + CHECK(strcmp(names[1], "vol2") == 0, "book[1]=%s", names[1]); + CHECK(strcmp(names[2], "vol10") == 0, "book[2]=%s", names[2]); + } + CHECK(book_index(names, n, "vol6") < 0, "dirs-only vol6 skipped (direct images only)"); + CHECK(book_index(names, n, ".hidden") < 0, "hidden skipped"); + CHECK(book_index(names, n, "MeowOS") < 0, "system dir without direct images skipped"); + + int i2 = book_index(names, n, "vol2"); + CHECK(i2 >= 0 && i2 + 1 < n && strcmp(names[i2 + 1], "vol10") == 0, + "vol2 next = vol10 (natural order, vol6 skipped)"); + CHECK(book_index(names, n, "vol10") == n - 1, "vol10 is last (no next)"); + CHECK(book_index(names, n, "vol1") == 0, "vol1 is first (no prev)"); +} + +int main(void) +{ + char tmpl[] = "./mr_lazy_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return 1; + + char p[MR_PATH_MAX]; + const char *dirs[] = { "book", "noimg", "empty", "dirs_only/deep", "series" }; + for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { + joinp(p, sizeof p, root, dirs[i]); + CHECK(mkdir_p(p) == 0, "mkdir %s", p); + } + + joinp(p, sizeof p, root, "book/page2.png"); CHECK(touch_file(p) == 0, "page2"); + joinp(p, sizeof p, root, "book/page10.jpg"); CHECK(touch_file(p) == 0, "page10"); + joinp(p, sizeof p, root, "book/notes.txt"); CHECK(touch_file(p) == 0, "notes"); + joinp(p, sizeof p, root, "noimg/readme.txt"); CHECK(touch_file(p) == 0, "readme"); + joinp(p, sizeof p, root, "dirs_only/deep/a.png"); CHECK(touch_file(p) == 0, "deep a"); + + /* M7 语义夹具:vol1/vol2/vol10 直接含图;vol6 只有子目录含图;.hidden / MeowOS 干扰项 */ + const char *mk[] = { "series/vol1/001.png", "series/vol2/001.png", + "series/vol10/001.png", "series/vol6/sub/001.png", + "series/.hidden/001.png", "series/MeowOS/deep/001.png" }; + for (unsigned i = 0; i < sizeof mk / sizeof mk[0]; i++) { + joinp(p, sizeof p, root, mk[i]); + /* 先建父目录,再落文件 */ + char dir[MR_PATH_MAX]; + snprintf(dir, sizeof dir, "%s", p); + char *slash = strrchr(dir, '/'); + if (slash) { *slash = '\0'; CHECK(mkdir_p(dir) == 0, "mkdir %s", dir); } + CHECK(touch_file(p) == 0, "image %s", p); + } + + test_probe_and_apply(root); + test_cache(root); + test_cache_race(root); + test_rows(root); + test_m7_semantics(root); + + rm_rf(root); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_browse_nav.c b/tuku/test/test_browse_nav.c new file mode 100644 index 0000000..58240a6 --- /dev/null +++ b/tuku/test/test_browse_nav.c @@ -0,0 +1,259 @@ +/* test_browse_nav.c — 浏览访问放开 / 上溯父目录 / 截断标注(纯 C,无 SDK)。 + * + * 覆盖(M13 用户要求②): + * N1 访问放开:系统目录(MeowAPPs/MeowData/MeowOS/lost+found)不再隐藏, + * 空目录/无图目录都列出且可进入(scan 返回 0 而不是 -1); + * N2 隐藏项/普通文件/符号链接仍不列(防环、防噪声); + * N3 不可读目录/不存在/不是目录:scan 返回 -1,调用方据此提示并停留; + * N4 上溯父目录:mr_path_parent 可一路走到文件系统 "/"(不再以 TF 卡根为界); + * N5 单目录条目上限:达到 MR_LIB_SCAN_MAX 后截断并置 truncated(行合成给出提示行)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ + * test/test_browse_nav.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + * -pthread -o build/host/test_browse_nav + */ +#include "logic/library.h" + +#include +#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)) { chmod(path, 0700); return unlink(path); } + chmod(path, 0700); + 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 void joinp(char *out, int outsz, const char *a, const char *b) +{ + snprintf(out, (size_t)outsz, "%s/%s", a, b); +} + +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; +} + +/* ---- N1/N2 ---- */ +static void test_open_access(const char *root) +{ + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + CHECK(n == 7, "root lists 7 dirs (got %d)", n); + CHECK(lib.truncated == 0, "root not truncated"); + + static const char *want[7] = { + "book", "empty", "lost+found", "MeowAPPs", "MeowData", "MeowOS", "noimg" + }; + for (int i = 0; i < 7 && i < lib.count; i++) + CHECK(strcmp(lib.items[i].name, want[i]) == 0, + "sort[%d]=%s got %s", i, want[i], lib.items[i].name); + + for (int i = 0; i < 7; i++) + CHECK(find_entry(&lib, want[i]) != NULL, "system/empty/noimg dir listed: %s", want[i]); + CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded"); + CHECK(find_entry(&lib, "linkdir") == NULL, "symlink dir excluded"); + CHECK(find_entry(&lib, "loose.png") == NULL, "file excluded"); + + /* 可进入:每个列出的目录都能被 scan(0 项也算成功,不等于 -1)。 */ + char p[MR_PATH_MAX]; + for (int i = 0; i < lib.count; i++) { + mr_library child; + memset(&child, 0, sizeof child); + CHECK(mr_library_scan(&child, lib.items[i].path, MR_LIB_MAX_DEPTH) >= 0, + "enter %s", lib.items[i].name); + mr_library_free(&child); + } + + joinp(p, sizeof p, root, "MeowOS"); + mr_library syslib; + memset(&syslib, 0, sizeof syslib); + CHECK(mr_library_scan(&syslib, p, MR_LIB_MAX_DEPTH) == 1 && + find_entry(&syslib, "sub") != NULL, "system dir browsable"); + mr_library_free(&syslib); + + joinp(p, sizeof p, root, "empty"); + mr_library empty; + memset(&empty, 0, sizeof empty); + CHECK(mr_library_scan(&empty, p, MR_LIB_MAX_DEPTH) == 0, "empty dir enterable (0 items)"); + mr_library_free(&empty); + + mr_library_free(&lib); +} + +/* ---- N3 ---- */ +static void test_unreadable(const char *root) +{ + mr_library lib; + char p[MR_PATH_MAX]; + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan(&lib, "/no/such/dir/m13", MR_LIB_MAX_DEPTH) == -1, "missing -> -1"); + CHECK(lib.count == 0 && lib.truncated == 0, "failed scan leaves empty lib"); + mr_library_free(&lib); + + joinp(p, sizeof p, root, "loose.png"); + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan(&lib, p, MR_LIB_MAX_DEPTH) == -1, "file (not dir) -> -1"); + mr_library_free(&lib); + + if (geteuid() != 0) { /* root 下 EACCES 不生效,跳过权限用例 */ + joinp(p, sizeof p, root, "noread"); + CHECK(mkdir_p(p) == 0, "mkdir noread"); + CHECK(chmod(p, 0000) == 0, "chmod 000"); + memset(&lib, 0, sizeof lib); + CHECK(mr_library_scan(&lib, p, MR_LIB_MAX_DEPTH) == -1, "unreadable -> -1"); + CHECK(lib.count == 0, "unreadable lib empty"); + mr_library_free(&lib); + chmod(p, 0700); + } +} + +/* ---- N4 ---- */ +static void test_up_to_root(void) +{ + char cur[MR_PATH_MAX]; + snprintf(cur, sizeof cur, "/usr/data/mnt/sd_0/a/b/c"); + int steps = 0; + while (strcmp(cur, "/") != 0 && steps < 32) { + char up[MR_PATH_MAX]; + int r = mr_path_parent(cur, up, (int)sizeof up); + CHECK(r == 0, "parent of %s", cur); + if (r != 0) break; + CHECK(strcmp(up, cur) != 0, "parent strictly shorter at %s", cur); + memcpy(cur, up, sizeof cur); + steps++; + } + CHECK(strcmp(cur, "/") == 0, "reached filesystem root ('%s')", cur); + CHECK(steps == 7, "steps to / = %d (want 7)", steps); + + char up[MR_PATH_MAX]; + CHECK(mr_path_parent("/", up, (int)sizeof up) == 0 && strcmp(up, "/") == 0, + "root parent stays /"); + CHECK(mr_path_parent("/usr/data/mnt/sd_0", up, (int)sizeof up) == 0 && + strcmp(up, "/usr/data/mnt") == 0, + "TF root can go above mount point: %s", up); +} + +/* ---- N5 ---- */ +static void test_truncation(void) +{ + CHECK(MR_LIB_SCAN_MAX == 2000, "default cap is 2000 (got %d)", MR_LIB_SCAN_MAX); + + char tmpl[] = "./mr_nav_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return; + + int total = MR_LIB_SCAN_MAX + 8; + char p[MR_PATH_MAX]; + for (int i = 0; i < total; i++) { + snprintf(p, sizeof p, "%s/d%04d", root, i); + if (mkdir(p, 0755) != 0) { CHECK(0, "mkdir %s", p); break; } + } + + mr_library lib; + memset(&lib, 0, sizeof lib); + int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + CHECK(n == MR_LIB_SCAN_MAX, "cap applied: n=%d", n); + CHECK(lib.truncated == 1, "truncated flag set"); + CHECK(lib.count == MR_LIB_SCAN_MAX, "count == cap"); + + mr_library_row row; + CHECK(mr_library_row_at(&lib, lib.count, &row) == 1 && row.disabled == 1, + "truncation row present"); + CHECK(strcmp(row.title, "已截断") == 0, "truncation row title=%s", row.title); + + mr_library_free(&lib); + rm_rf(root); +} + +int main(void) +{ + char tmpl[] = "./mr_nav_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return 1; + + char p[MR_PATH_MAX]; + const char *dirs[] = { "MeowAPPs", "MeowData", "MeowOS/sub", "lost+found", + "book", "empty", "noimg", ".hidden" }; + for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { + joinp(p, sizeof p, root, dirs[i]); + CHECK(mkdir_p(p) == 0, "mkdir %s", p); + } + joinp(p, sizeof p, root, "MeowOS/001.png"); + touch_file(p); + joinp(p, sizeof p, root, "book/001.png"); + touch_file(p); + joinp(p, sizeof p, root, "noimg/readme.txt"); + touch_file(p); + joinp(p, sizeof p, root, "loose.png"); + touch_file(p); + /* 符号链接目录不参与浏览(有权限才建;建不了不影响其余断言)。 */ + char target[MR_PATH_MAX], link[MR_PATH_MAX]; + joinp(target, sizeof target, root, "book"); + joinp(link, sizeof link, root, "linkdir"); + symlink(target, link); + + test_open_access(root); + test_unreadable(root); + test_up_to_root(); + + rm_rf(root); + test_truncation(); + + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_browse_perf.c b/tuku/test/test_browse_perf.c new file mode 100644 index 0000000..596f9d4 --- /dev/null +++ b/tuku/test/test_browse_perf.c @@ -0,0 +1,199 @@ +/* test_browse_perf.c — 单层扫描性能/有界性验收(纯 C,无 SDK)。 + * + * 夹具:500 个顶层目录 × 8 层嵌套(4000 个子目录)+ 根目录 2000 个文件。 + * 断言(对应 M13 任务⑤): + * P1 首帧扫描只做单层:opendir 次数 == 1(旧实现会递归打开数千个目录); + * lstat 次数 == 本层条目数;耗时有界; + * P2 后台探测同样只读一层:每个目录一次 opendir,不进入其嵌套子树; + * P3 行合成(UI src/bind 的数据面)纯内存、零 IO、O(1); + * P4 深目录直接扫描也只打开它自己。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. -I../sdk -DMR_LIBRARY_TEST_HOOKS \ + * test/test_browse_perf.c logic/library.c logic/reader.c test/settings_reader_stub.c \ + * -pthread -o build/host/test_browse_perf + */ +#include "logic/library.h" + +#include +#include +#include +#include +#include +#include +#include + +#define PERF_DIRS 500 +#define PERF_DEPTH 8 +#define PERF_FILES 2000 + +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 long long now_us(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long)ts.tv_sec * 1000000LL + ts.tv_nsec / 1000LL; +} + +int main(void) +{ + char tmpl[] = "./mr_perf_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return 1; + + char p[MR_PATH_MAX]; + /* 根目录:2000 个文件 + 500 个顶层目录(每个顶层目录内是 8 层嵌套 + 1 张图)。 */ + for (int f = 0; f < PERF_FILES; f++) { + snprintf(p, sizeof p, "%s/f%04d.txt", root, f); + if (touch_file(p) != 0) { CHECK(0, "file %s", p); break; } + } + for (int d = 0; d < PERF_DIRS; d++) { + char deep[MR_PATH_MAX]; + snprintf(deep, sizeof deep, "%s/d%04d", root, d); + if (mkdir_p(deep) != 0) { CHECK(0, "mkdir %s", deep); break; } + snprintf(p, sizeof p, "%s/001.png", deep); /* 顶层目录自身直接含图(=一本书) */ + if (touch_file(p) != 0) { CHECK(0, "touched top image"); break; } + for (int l = 1; l <= PERF_DEPTH; l++) { + char sub[MR_PATH_MAX]; + snprintf(sub, sizeof sub, "%s/l%d", deep, l); + if (mkdir(sub, 0755) != 0) { CHECK(0, "mkdir %s", sub); break; } + snprintf(deep, sizeof deep, "%s", sub); + } + snprintf(p, sizeof p, "%s/001.png", deep); + if (touch_file(p) != 0) { CHECK(0, "touched deep image"); break; } + } + + /* ---- P1:首帧扫描只做单层 ---- */ + mr_library lib; + memset(&lib, 0, sizeof lib); + mr_library_test_io_reset(); + long long t0 = now_us(); + int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); + long long scan_us = now_us() - t0; + mr_library_io_stats st; + mr_library_test_io_get(&st); + + CHECK(n == PERF_DIRS, "scan lists %d dirs (got %d)", PERF_DIRS, n); + CHECK(lib.truncated == 0, "not truncated"); + CHECK(st.opendirs == 1, "single-layer scan: opendir=%ld (want 1)", st.opendirs); + CHECK(st.lstats == PERF_FILES + PERF_DIRS, + "lstat only current level: %ld (want %d)", st.lstats, PERF_FILES + PERF_DIRS); + CHECK(scan_us < 1500000LL, "scan bounded: %lldus", scan_us); + if (lib.count > 1) { + CHECK(strcmp(lib.items[0].name, "d0000") == 0, "first=%s", lib.items[0].name); + CHECK(strcmp(lib.items[lib.count - 1].name, "d0499") == 0, + "last=%s", lib.items[lib.count - 1].name); + } + printf(" scan: %lldus, %d dirs, opendir=%ld lstat=%ld\n", + scan_us, n, st.opendirs, st.lstats); + + /* ---- P2:后台探测每目录只开一次,不进入嵌套子树 ---- */ + mr_library_test_io_reset(); + long long p0 = now_us(); + int bad = 0; + for (int i = 0; i < lib.count; i++) { + mr_library_probe probe; + if (mr_library_probe_cached(lib.items[i].path, &probe) != 0 || probe.pages != 1) + bad++; + } + long long probe_us = now_us() - p0; + mr_library_test_io_get(&st); + CHECK(bad == 0, "all top dirs have 1 direct image (bad=%d)", bad); + CHECK(st.opendirs == PERF_DIRS, + "probe stays shallow: opendir=%ld (want %d, not %d)", + st.opendirs, PERF_DIRS, PERF_DIRS * (PERF_DEPTH + 1)); + CHECK(st.lstats == PERF_DIRS, + "probe lstats only image candidates: %ld (want %d)", st.lstats, PERF_DIRS); + CHECK(probe_us < 5000000LL, "probe bounded: %lldus", probe_us); + printf(" probe: %lldus, opendir=%ld lstat=%ld\n", probe_us, st.opendirs, st.lstats); + + /* ---- P3:行合成(bind/src 数据面)纯内存 O(1) ---- */ + mr_library_test_io_reset(); + long long r0 = now_us(); + int rows = 0; + for (int r = 0; r < 1000; r++) + for (int i = 0; i < lib.count; i++) { + mr_library_row row; + if (mr_library_row_at(&lib, i, &row)) rows++; + } + long long row_us = now_us() - r0; + mr_library_test_io_get(&st); + CHECK(rows == 1000 * lib.count, "rows synthesized (%d)", rows); + CHECK(st.opendirs == 0 && st.readdirs == 0 && st.lstats == 0, + "row synthesis does no IO (open=%ld rd=%ld stat=%ld)", + st.opendirs, st.readdirs, st.lstats); + CHECK(row_us < 1000000LL, "row synthesis bounded: %lldus", row_us); + printf(" rows: %lldus for %d rows, zero IO\n", row_us, rows); + + /* ---- P4:直接扫描深层目录同样只打开它自己 ---- */ + char deep[MR_PATH_MAX]; + snprintf(deep, sizeof deep, "%s", lib.items[0].path); + for (int l = 1; l <= PERF_DEPTH; l++) { + char sub[MR_PATH_MAX]; + snprintf(sub, sizeof sub, "%s/l%d", deep, l); + snprintf(deep, sizeof deep, "%s", sub); + } + mr_library_test_io_reset(); + mr_library deep_lib; + memset(&deep_lib, 0, sizeof deep_lib); + CHECK(mr_library_scan(&deep_lib, deep, MR_LIB_MAX_DEPTH) == 0, "deep leaf has no subdir"); + mr_library_test_io_get(&st); + CHECK(st.opendirs == 1, "deep scan opens once (got %ld)", st.opendirs); + CHECK(st.lstats == 1, "deep scan lstats only its image (got %ld)", st.lstats); + mr_library_free(&deep_lib); + + mr_library_free(&lib); + rm_rf(root); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_browse_ui_host.c b/tuku/test/test_browse_ui_host.c new file mode 100644 index 0000000..b1bbe73 --- /dev/null +++ b/tuku/test/test_browse_ui_host.c @@ -0,0 +1,269 @@ +/* test_browse_ui_host.c — 书库浏览页宿主集成测试(可选,需 SDK 源码树)。 + * + * 目的(M13): + * U1 列表用 src2 模式(每帧重取行):扫描后行副标题是占位「…」,后台探测完成、 + * on_tick 补位后同一行立即显示页数——验证懒算结果能上屏(固定池 vsource 做不到); + * U2 行语义:书行显示 "N pages"(无进入箭头),空目录「空目录」,无图目录「无图片」; + * U3 导航:可逐级上溯父目录;文件系统 "/" 处 library_go_up 返回 0(app.c 据此退出); + * 「TF卡」回根动作回到书库根; + * U4 不可读目录:load 失败 → 停留原目录 + 顶部提示横幅。 + * + * 编译:见 test/run_tests.sh(与 test_settings_lock_host 同口径:链 SDK 源码树 + 打桩; + * 只发布 meow_ui.a 的交付环境自动 SKIP)。ui/library/library.c 以 + * -DMR_LIBRARY_UI_TEST_HOOKS 编译出测试接缝;本测试另打桩 mr_reader_open_dir/mr_nav_report。 + */ +#include "tuku_internal.h" +#include "logic/library.h" + +#include +#include +#include +#include +#include +#include + +/* ---- ui/library/library.c 的测试接缝(仅 -DMR_LIBRARY_UI_TEST_HOOKS 时定义) ---- */ +int library_test_load(const char *dir); +void library_test_set_root(const char *root); +const char *library_test_root(void); +const char *library_test_dir(void); +void library_test_set_dir(const char *dir); +int library_test_rows(void); +int library_test_src(int idx, meow_list_item *item); +int library_test_hint_active(void); +const char *library_test_hint(void); +int library_test_go_root(void); +int library_test_go_up(void); + +static int g_fail; +#define CHECK(cond, ...) do { \ + if (cond) { printf(" PASS\n"); } \ + else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \ +} while (0) + +/* app.c 的导航上报在测试里不需要网络侧实现。 */ +void mr_nav_report(void) {} + +/* reader 打桩:只记录被打开的册路径(本测试不建 reader 页)。 */ +static char g_opened[MR_PATH_MAX]; +static int g_opened_n; +void mr_reader_open_dir(const char *path) +{ + snprintf(g_opened, sizeof g_opened, "%s", path ? path : ""); + g_opened_n++; +} + +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)) { chmod(path, 0700); return unlink(path); } + chmod(path, 0700); + 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 void joinp(char *out, int outsz, const char *a, const char *b) +{ + snprintf(out, (size_t)outsz, "%s/%s", a, b); +} + +static int row_index_for(const char *name) +{ + int rows = library_test_rows(); + for (int i = 0; i < rows; i++) { + meow_list_item it; + if (!library_test_src(i, &it)) continue; + if (it.title && strcmp(it.title, name) == 0) return i; + } + return -1; +} + +static void wait_ready(int ms) +{ + for (int t = 0; t < ms / 10; t++) { + library_on_tick(); + int ready = 1; + int rows = library_test_rows(); + for (int i = 0; i < rows; i++) { + meow_list_item it; + if (!library_test_src(i, &it)) continue; + if (it.subtitle && strcmp(it.subtitle, "…") == 0) { ready = 0; break; } + } + if (ready) return; + usleep(10000); + } +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_browse_ui_XXXXXX", base); + char *data = mkdtemp(tmpl); + if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; } + setenv("MEOW_DATA_DIR", data, 1); + + char root[MR_PATH_MAX]; + snprintf(root, sizeof root, "%s/library", data); + char p[MR_PATH_MAX]; + const char *dirs[] = { "book", "empty", "noimg", "deep/sub", "MeowOS/sub" }; + for (unsigned i = 0; i < sizeof dirs / sizeof dirs[0]; i++) { + joinp(p, sizeof p, root, dirs[i]); + if (mkdir_p(p) != 0) { printf("SKIP: mkdir %s failed\n", p); return 0; } + } + joinp(p, sizeof p, root, "book/page2.png"); touch_file(p); + joinp(p, sizeof p, root, "book/page10.jpg"); touch_file(p); + joinp(p, sizeof p, root, "noimg/readme.txt"); touch_file(p); + joinp(p, sizeof p, root, "deep/sub/001.png"); touch_file(p); + joinp(p, sizeof p, root, "MeowOS/sub/m.png"); touch_file(p); + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + meow_view *lib = library_view_create(); + library_test_set_root(root); + CHECK(strcmp(library_test_root(), root) == 0, "test root installed"); + meow_view_page_push(ACT_LIBRARY); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "library active"); + + /* ---- U1:src2 行 + 懒算补位 ---- */ + CHECK(library_test_load(root) == 1, "load fixture"); + CHECK(library_test_rows() == 5, "5 rows (got %d)", library_test_rows()); + + static const char *want[5] = { "book", "deep", "empty", "MeowOS", "noimg" }; + for (int i = 0; i < 5; i++) { + meow_list_item it; + CHECK(library_test_src(i, &it) == 1, "src row %d", i); + CHECK(it.title && strcmp(it.title, want[i]) == 0, "row[%d]=%s (want %s)", + i, it.title ? it.title : "(null)", want[i]); + CHECK(it.subtitle && strcmp(it.subtitle, "…") == 0, + "row[%d] starts with placeholder (got %s)", i, it.subtitle ? it.subtitle : ""); + CHECK(it.right == MEOW_LIST_RIGHT_ARROW, "row[%d] has enter arrow while pending", i); + } + + wait_ready(3000); + /* U2:补位后的行语义 */ + for (int i = 0; i < 5; i++) { + meow_list_item it; + library_test_src(i, &it); + CHECK(it.subtitle && strcmp(it.subtitle, "…") != 0, + "row[%d] subtitle filled (got %s)", i, it.subtitle ? it.subtitle : ""); + } + int ib = row_index_for("book"); + int ie = row_index_for("empty"); + int in = row_index_for("noimg"); + int id = row_index_for("deep"); + meow_list_item it; + CHECK(ib >= 0 && library_test_src(ib, &it) && strcmp(it.subtitle, "2 pages") == 0, + "book subtitle=%s", it.subtitle ? it.subtitle : ""); + CHECK(it.right == MEOW_LIST_RIGHT_NONE, "book row has no enter arrow (opens reader)"); + CHECK(ie >= 0 && library_test_src(ie, &it) && strcmp(it.subtitle, "空目录") == 0, + "empty subtitle=%s", it.subtitle ? it.subtitle : ""); + CHECK(in >= 0 && library_test_src(in, &it) && strcmp(it.subtitle, "无图片") == 0, + "noimg subtitle=%s", it.subtitle ? it.subtitle : ""); + CHECK(id >= 0 && library_test_src(id, &it) && strcmp(it.subtitle, "无图片") == 0, + "deep (images only in subdir) is not a book: %s", it.subtitle ? it.subtitle : ""); + + /* ---- 行点击:书 → reader;空目录 → 进入 ---- */ + int list_top = meow_ui_statusbar_height() + 56; + g_opened_n = 0; + library_on_tap(120, list_top + ib * 56 + 28); + CHECK(g_opened_n == 1 && strstr(g_opened, "/book") != NULL, + "tap book opens reader (%s)", g_opened); + CHECK(strcmp(library_test_dir(), root) == 0, "tap book does not navigate"); + + library_on_tap(120, list_top + ie * 56 + 28); + char empty_path[MR_PATH_MAX]; + joinp(empty_path, sizeof empty_path, root, "empty"); + CHECK(strcmp(library_test_dir(), empty_path) == 0, + "tap empty dir enters it (%s)", library_test_dir()); + CHECK(library_test_rows() == 0, "empty dir shows empty list"); + + /* ---- U3:导航(逐级上溯 / 到 / 退出 / 回 TF 根) ---- */ + CHECK(library_test_go_up() == 1, "go_up from empty consumed"); + CHECK(strcmp(library_test_dir(), root) == 0, "go_up to fixture root"); + + char deep[MR_PATH_MAX]; + joinp(deep, sizeof deep, root, "deep/sub"); + CHECK(library_test_load(deep) == 1, "load deep/sub"); + CHECK(library_test_go_up() == 1, "go_up from deep/sub"); + joinp(p, sizeof p, root, "deep"); + CHECK(strcmp(library_test_dir(), p) == 0, "now at deep"); + + /* 「TF卡」回根 */ + CHECK(library_test_load(deep) == 1, "reload deep/sub"); + CHECK(library_test_go_root() == 1 && strcmp(library_test_dir(), root) == 0, + "TF卡 action returns to library root"); + + /* 上溯到文件系统 /:go_up 在 / 返回 0(app.c 据此退出)。 + * 宿主(Termux)读不了 "/":从 /usr 返回时 load("/") 失败,仍应切到 "/" 的空列表 + * (否则会卡在 /usr 无法退出),下一次返回=退出 App。 */ + library_test_set_dir("/usr"); + CHECK(library_test_go_up() == 1, "go_up toward / consumed"); + CHECK(strcmp(library_test_dir(), "/") == 0, "reached / (unreadable root fallback)"); + CHECK(library_test_go_up() == 0, "go_up at / returns 0 (app exits)"); + CHECK(strcmp(library_test_dir(), "/") == 0, "still at / after exit gesture"); + + /* ---- U4:不可读目录 → 停留 + 提示 ---- */ + CHECK(library_test_load(root) == 1, "back to fixture root"); + char noread[MR_PATH_MAX]; + joinp(noread, sizeof noread, root, "noread"); + if (geteuid() != 0) { + CHECK(mkdir(noread, 0755) == 0, "mkdir noread"); + chmod(noread, 0000); + int ok = library_test_load(noread); + CHECK(ok == 0, "unreadable dir load fails"); + CHECK(strcmp(library_test_dir(), root) == 0, "stays in previous dir (%s)", + library_test_dir()); + CHECK(library_test_hint_active(), "hint banner shown: %s", library_test_hint()); + CHECK(strstr(library_test_hint(), "无法读取") != NULL, + "hint text mentions unreadable: %s", library_test_hint()); + chmod(noread, 0700); + } else { + printf(" SKIP unreadable-dir case (running as root)\n"); + } + + meow_view_free(lib); + meow_ui_shutdown(); + rm_rf(data); + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "browse UI checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_cbz.c b/tuku/test/test_cbz.c index 4e4b29f..97cc465 100644 --- a/tuku/test/test_cbz.c +++ b/tuku/test/test_cbz.c @@ -15,12 +15,13 @@ * T6 坏档容错:非 zip/截断/无图片页/声明 96MiB 的 zip 炸弹 → 打开失败或 0 页,不崩。 * T7 统一开册契约:目录册与档案册走同一个 mr_book(count/meta/open/close/destroy), * 越界 open 返回 NULL,close_auto 幂等。 - * T8 书库列档:.cbz 与文件夹册同列自然序(bookA < box < vol2.cbz < Vol10.cbz), - * 行=文件名+页数;坏档/无图档案不列;只含档案册的目录写 "N books"; - * 不注入探测回调时行为与 M7 完全一致(纯目录书库)。 + * T8 书库列档(M13 单层 + 懒判定):.cbz 与文件夹册同列自然序(bookA < box < ... < + * Vol10.cbz);扫描期只成行、副标题占位,探测补位后才有页数/书标记; + * 坏档/无图片页档案仍列出但为非书(访问放开,不再隐藏); + * 不注入探测回调时档案文件不成行(M7 语义),目录仍按单层列出。 * T9 册间衔接:同父目录自然序混排(bookA → vol2.cbz → Vol10.cbz), * 首/末册返回 0;不注入探测回调时档案册不参与(M7 语义)。 - * T10 续读角标:档案路径作为册标识,副标题后缀「· 续读 Pn」。 + * T10 续读角标(M13 两段式):档案路径作为册标识,probe 补位后副标题后缀「· 续读 Pn」。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -Werror -O1 -I. test/test_cbz.c logic/library.c logic/reader.c \ @@ -715,19 +716,49 @@ static const mr_library_entry *lib_find(const mr_library *lib, const char *name) return NULL; } +/* M13:模拟 UI 后台探测补位(目录单层探测 / 档案册注入页数回调)。 */ +static void lib_probe_all(mr_library *lib) +{ + for (int i = 0; i < lib->count; i++) { + mr_library_probe p; + if (mr_library_probe_path(lib->items[i].path, lib->items[i].is_archive, + archive_pages_probe, NULL, &p) == 0) + mr_library_apply_probe(lib, i, &p); + else + mr_library_mark_error(lib, i); + } +} + static void test_library(void) { mr_test_cfg_clear(); + /* M13:扫描只做单层枚举——目录(全部列出,含无图的 box)与档案文件(.cbz/.zip) + * 都先成行、副标题占位;书/非书与页数由后台 probe 补位。 */ mr_library lib; memset(&lib, 0, sizeof lib); int n = mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); - CHECK(n == 4, "library rows=%d (want bookA, box, vol2.cbz, Vol10.cbz)", n); - if (n == 4) { - CHECK(strcmp(lib.items[0].name, "bookA") == 0, "row0=%s", lib.items[0].name); - CHECK(strcmp(lib.items[1].name, "box") == 0, "row1=%s", lib.items[1].name); - CHECK(strcmp(lib.items[2].name, "vol2.cbz") == 0, "row2=%s", lib.items[2].name); - CHECK(strcmp(lib.items[3].name, "Vol10.cbz") == 0, "row3=%s", lib.items[3].name); + CHECK(n == 8, "library rows=%d (want 2 dirs + 6 archives)", n); + static const char *want[8] = { + "bookA", "box", "broken.cbz", "emptypages.cbz", + "notabook.zip", "truncated.cbz", "vol2.cbz", "Vol10.cbz" + }; + for (int i = 0; i < 8 && i < lib.count; i++) { + CHECK(strcmp(lib.items[i].name, want[i]) == 0, + "row[%d]=%s (want %s)", i, lib.items[i].name, want[i]); + CHECK(lib.items[i].probe_state == MR_LIB_PENDING && lib.items[i].pages == -1, + "%s pending after scan", lib.items[i].name); + CHECK(strcmp(lib.items[i].subtitle, "…") == 0, + "%s placeholder subtitle=%s", lib.items[i].name, lib.items[i].subtitle); + } + + /* 后台补位(等价 UI 的 on_tick):目录走单层目录探测,档案走注入的 zip 页数回调。 */ + for (int i = 0; i < lib.count; i++) { + mr_library_probe p; + int ok = (mr_library_probe_path(lib.items[i].path, lib.items[i].is_archive, + archive_pages_probe, NULL, &p) == 0); + CHECK(ok, "%s probed", lib.items[i].name); + if (ok) mr_library_apply_probe(&lib, i, &p); } const mr_library_entry *e = lib_find(&lib, "bookA"); @@ -737,15 +768,17 @@ static void test_library(void) CHECK(e && strstr(e->cover, "001.png") != NULL, "bookA cover=%s", e ? e->cover : ""); + /* box 只含档案册:目录本身不直接含图 → 不是书(M13 不做递归/子表统计)。 */ e = lib_find(&lib, "box"); - CHECK(e && e->has_images == 0 && e->subdirs == 0, "box fields"); - CHECK(e && strcmp(e->subtitle, "2 books") == 0, "box subtitle=%s", + CHECK(e && e->has_images == 0 && e->subdirs == 0 && e->is_archive == 0, + "box fields"); + CHECK(e && strcmp(e->subtitle, "无图片") == 0, "box subtitle=%s", e ? e->subtitle : ""); CHECK(e && e->cover[0] == '\0', "box cover should be empty"); e = lib_find(&lib, "vol2.cbz"); - CHECK(e && e->pages == 3 && e->has_images == 1 && e->subdirs == 0, - "vol2.cbz fields"); + CHECK(e && e->pages == 3 && e->has_images == 1 && e->subdirs == 0 && + e->is_archive == 1, "vol2.cbz fields"); CHECK(e && strcmp(e->subtitle, "3 pages") == 0, "vol2.cbz subtitle=%s", e ? e->subtitle : ""); CHECK(e && e->cover[0] == '\0', "archive cover should be empty (UI extracts)"); @@ -756,19 +789,24 @@ static void test_library(void) CHECK(e && e->pages == 1 && strcmp(e->subtitle, "1 page") == 0, "Vol10.cbz pages/subtitle"); - CHECK(lib_find(&lib, "notabook.zip") == NULL, "text-only zip listed"); - CHECK(lib_find(&lib, "emptypages.cbz") == NULL, "no-image cbz listed"); - CHECK(lib_find(&lib, "broken.cbz") == NULL, "broken cbz listed"); - CHECK(lib_find(&lib, "truncated.cbz") == NULL, "truncated cbz listed"); - CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz listed"); + /* 坏档/无图片页档案:列出但探测为非书(M13 访问放开,不再隐藏)。 */ + static const char *not_book[] = { "notabook.zip", "emptypages.cbz", + "broken.cbz", "truncated.cbz" }; + for (unsigned i = 0; i < sizeof not_book / sizeof not_book[0]; i++) { + e = lib_find(&lib, not_book[i]); + CHECK(e && e->is_archive == 1 && e->has_images == 0 && e->pages == 0 && + strcmp(e->subtitle, "无图片") == 0, + "%s: not a book (subtitle=%s)", not_book[i], e ? e->subtitle : "(missing)"); + } + CHECK(lib_find(&lib, ".hidden.cbz") == NULL, "hidden cbz not listed"); mr_library_free(&lib); - /* 不注入探测回调:与 M7 完全一致(档案册不参与,只含档案册的目录也不 nav)。 */ + /* 不注入探测回调:M7 纯目录书库(档案文件不成行,目录仍按 M13 单层列出)。 */ memset(&lib, 0, sizeof lib); - CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 1, - "legacy scan rows=%d (want 1: bookA)", lib.count); - CHECK(lib.count >= 1 && strcmp(lib.items[0].name, "bookA") == 0, - "legacy scan row0"); + CHECK(mr_library_scan(&lib, g_lib, MR_LIB_MAX_DEPTH) == 2, + "legacy scan rows=%d (want 2: bookA, box)", lib.count); + CHECK(lib.count >= 2 && strcmp(lib.items[0].name, "bookA") == 0 && + strcmp(lib.items[1].name, "box") == 0, "legacy scan rows"); mr_library_free(&lib); } @@ -835,11 +873,16 @@ static void test_badge_cover(void) mr_test_cfg_clear(); mr_cont_save(vol2, 2, MR_MODE_WEBTOON); + /* M13 两段式:扫描期只有占位副标题;后台 probe 补位后才出现页数与续读角标。 */ mr_library lib; memset(&lib, 0, sizeof lib); CHECK(mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, - NULL) == 4, "rescan for badge"); + NULL) == 8, "rescan for badge (single-layer rows)"); const mr_library_entry *e = lib_find(&lib, "vol2.cbz"); + CHECK(e && strcmp(e->subtitle, "…") == 0, "vol2 placeholder before probe: %s", + e ? e->subtitle : ""); + lib_probe_all(&lib); + e = lib_find(&lib, "vol2.cbz"); CHECK(e && strstr(e->subtitle, "续读 P3") != NULL, "badge subtitle=%s", e ? e->subtitle : ""); CHECK(e && strstr(e->subtitle, "3 pages") != NULL, "badge keeps page text: %s", @@ -848,10 +891,11 @@ static void test_badge_cover(void) CHECK(e && strstr(e->subtitle, "续读") == NULL, "Vol10 should have no badge"); mr_library_free(&lib); - /* 清除记录后角标消失 */ + /* 清除记录后角标消失(重扫 + 再探测;探测缓存只存页数,角标按当前配置重算)。 */ mr_cont_save(vol2, 0, MR_MODE_WEBTOON); memset(&lib, 0, sizeof lib); mr_library_scan_ex(&lib, g_lib, MR_LIB_MAX_DEPTH, archive_pages_probe, NULL); + lib_probe_all(&lib); e = lib_find(&lib, "vol2.cbz"); CHECK(e && strstr(e->subtitle, "续读") == NULL, "cleared badge gone"); mr_library_free(&lib); diff --git a/tuku/test/test_cont_adjacent.c b/tuku/test/test_cont_adjacent.c index 298f604..d2b682f 100644 --- a/tuku/test/test_cont_adjacent.c +++ b/tuku/test/test_cont_adjacent.c @@ -6,11 +6,12 @@ * A3 边界:末页前进→下一册、首页后退→上一册(调用方语义);路径末尾斜杠等价; * A4 排除:隐藏/系统目录、符号链接、普通文件不参与兄弟册; * A5 参数:delta 非 ±1、NULL/小缓冲、不存在的目录; - * A6 书库扫描接线:有续读记录的册,行副标题出现「· 续读 Pn」角标。 + * A6 书库扫描接线(M13 两段式):scan 只列单层、副标题占位「…」; + * probe/on_tick 补位后出现页数与「· 续读 Pn」角标。 * * 编译(在 tuku/ 下): * gcc -Wall -Wextra -O1 -I. test/test_cont_adjacent.c logic/library.c logic/reader.c \ - * test/settings_reader_stub.c -o test/test_cont_adjacent + * test/settings_reader_stub.c -pthread -o test/test_cont_adjacent * 或 test/run_webtoon_tests.sh。 */ #include "logic/library.h" @@ -158,6 +159,20 @@ static void test_adjacent(const char *root) CHECK(mr_book_adjacent("/", 1, out, (int)sizeof out) == -1, "root path invalid"); } +/* 模拟 UI 后台探测:逐行单层 probe 并补位(page>0 的书行由 format_subtitle 拼续读角标)。 */ +static void probe_all_rows(mr_library *lib) +{ + for (int i = 0; i < lib->count; i++) { + mr_library_probe p; + if (mr_library_probe_cached(lib->items[i].path, &p) == 0) + mr_library_apply_probe(lib, i, &p); + else + mr_library_mark_error(lib, i); + } +} + +/* A6 两段式(M13):scan 只做单层枚举、行副标题是占位「…」;后台 probe/on_tick 补位后 + * 才有页数与「· 续读 Pn」角标。 */ static void test_library_badge(const char *root) { mr_test_cfg_clear(); @@ -173,28 +188,52 @@ static void test_library_badge(const char *root) int n = mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH); CHECK(n > 0, "library scan n=%d", n); + /* 第一段:扫描期不判定,占位副标题。 */ int seen1 = 0, seen2 = 0; for (int i = 0; i < lib.count; i++) { mr_library_entry *e = &lib.items[i]; if (strcmp(e->name, "vol1") == 0) { seen1 = 1; - CHECK(strstr(e->subtitle, "续读 P5") != NULL, "vol1 subtitle=%s", e->subtitle); - CHECK(strstr(e->subtitle, "page") != NULL, "vol1 keeps pages text: %s", e->subtitle); + CHECK(e->probe_state == MR_LIB_PENDING && e->pages == -1, + "vol1 pending after scan"); + CHECK(strcmp(e->subtitle, "…") == 0, "vol1 placeholder subtitle=%s", e->subtitle); } if (strcmp(e->name, "vol2") == 0) { seen2 = 1; - CHECK(strstr(e->subtitle, "续读") == NULL, "vol2 no badge: %s", e->subtitle); - CHECK(strcmp(e->subtitle, "1 page") == 0, "vol2 subtitle=%s", e->subtitle); + CHECK(e->probe_state == MR_LIB_PENDING && strcmp(e->subtitle, "…") == 0, + "vol2 placeholder subtitle=%s", e->subtitle); } } CHECK(seen1 == 1, "vol1 listed"); CHECK(seen2 == 1, "vol2 listed"); + + /* 第二段:后台探测补位后,页数与续读角标出现。 */ + probe_all_rows(&lib); + seen1 = seen2 = 0; + for (int i = 0; i < lib.count; i++) { + mr_library_entry *e = &lib.items[i]; + if (strcmp(e->name, "vol1") == 0) { + seen1 = 1; + CHECK(e->has_images == 1 && e->pages == 1, "vol1 probed pages=%d", e->pages); + CHECK(strstr(e->subtitle, "page") != NULL, "vol1 keeps pages text: %s", e->subtitle); + CHECK(strstr(e->subtitle, "续读 P5") != NULL, "vol1 subtitle=%s", e->subtitle); + } + if (strcmp(e->name, "vol2") == 0) { + seen2 = 1; + CHECK(e->has_images == 1 && e->pages == 1, "vol2 probed pages=%d", e->pages); + CHECK(strstr(e->subtitle, "续读") == NULL, "vol2 no badge: %s", e->subtitle); + CHECK(strcmp(e->subtitle, "1 page") == 0, "vol2 subtitle=%s", e->subtitle); + } + } + CHECK(seen1 == 1, "vol1 listed after probe"); + CHECK(seen2 == 1, "vol2 listed after probe"); mr_library_free(&lib); - /* 记录清除后角标消失 */ + /* 记录清除后角标消失(重扫 + 再探测;探测缓存只存页数,角标按当前配置重算)。 */ mr_cont_save(vol1, 0, MR_MODE_WEBTOON); memset(&lib, 0, sizeof lib); CHECK(mr_library_scan(&lib, root, MR_LIB_MAX_DEPTH) > 0, "rescan"); + probe_all_rows(&lib); for (int i = 0; i < lib.count; i++) if (strcmp(lib.items[i].name, "vol1") == 0) CHECK(strstr(lib.items[i].subtitle, "续读") == NULL, "cleared badge gone"); diff --git a/tuku/test/test_library.c b/tuku/test/test_library.c index 65b0e2e..2b4e2a6 100644 --- a/tuku/test/test_library.c +++ b/tuku/test/test_library.c @@ -3,12 +3,14 @@ * 覆盖: * T1 自然排序:数字段数值序(page2pages == 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, "MeowAPPs") != NULL, "system dir listed"); + CHECK(find_entry(&lib, "Empty") != NULL, "empty dir listed"); + CHECK(find_entry(&lib, "Series 3") != NULL, "no-image dir listed"); + CHECK(find_entry(&lib, "Nested") != NULL, "intermediate dir listed"); CHECK(find_entry(&lib, ".hidden") == NULL, "hidden dir excluded"); - CHECK(find_entry(&lib, "MeowAPPs") == NULL, "system dir excluded"); + CHECK(find_entry(&lib, "root.png") == NULL, "loose image file excluded"); - /* 深度 1:Nested 不再可探测到 Deeper 的图,应被排除。 */ + /* 首帧不做懒判定:所有行待探测(副标题占位)。 */ + for (int i = 0; i < lib.count; i++) { + CHECK(lib.items[i].pages == -1, "%s pages pending", lib.items[i].name); + CHECK(lib.items[i].has_images == -1, "%s book state pending", lib.items[i].name); + CHECK(lib.items[i].probe_state == MR_LIB_PENDING, "%s probe pending", lib.items[i].name); + CHECK(strcmp(lib.items[i].subtitle, "…") == 0, "%s subtitle placeholder", lib.items[i].name); + } + + /* max_depth 参数只为兼容旧调用保留:不再递归,结果一致。 */ mr_library lib1; memset(&lib1, 0, sizeof lib1); - CHECK(mr_library_scan(&lib1, root, 1) == 3, "max_depth=1 excludes Nested"); + CHECK(mr_library_scan(&lib1, root, 1) == 7, "max_depth ignored (single layer)"); mr_library_free(&lib1); mr_library_free(&lib); diff --git a/tuku/tuku_internal.h b/tuku/tuku_internal.h index caad8db..dffae61 100644 --- a/tuku/tuku_internal.h +++ b/tuku/tuku_internal.h @@ -38,7 +38,7 @@ typedef struct 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=已在根 */ +int library_go_up(void); /* 上溯父目录:1=已上移/提示停留,0=已在文件系统 "/"(app.c 据此退出) */ 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); diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index 7d6083a..0332796 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -1,20 +1,27 @@ -/* ui/library/library.c — 图库根页(ACT_LIBRARY): +/* ui/library/library.c — 图库浏览页(ACT_LIBRARY): * - * TopAppBar(标题 + 返回 + ⚙ 位图动作)+ meow_listview 虚拟化目录列表。 - * 任意文件夹可浏览;无元数据但含 PNG/JPEG/BMP 的文件夹即一本书(含图目录=册); - * M11 起 .cbz/.zip 也作为「册」与文件夹并列(行=封面+文件名+页数,点入直接阅读)。 - * 行数据由 logic/library 扫描并自然排序;档案册页数/首图由 logic/reader 的 zip 页源提供。 + * TopAppBar(标题 + 返回 + 「TF卡」回根 + ⚙)+ meow_listview(src2 行数据源)。 * - * 流畅度(用户指令优先): - * - 首帧只绘文字行:bind 不读盘;缩略图请求投递给后台线程(pthread)异步解码, - * 完成后在 on_tick 补位并只 invalidate 列表视口; - * - 缩略图 meow_img_downscale 到 96px,并落磁盘缓存 MeowData//cache - * (BMP 原始缩略图,文件名含源 mtime+size,源文件变化自动失效;≥64 张按 - * mtime LRU 删除);档案册封面=档内首图,先解压到同一 cache(page_cache 管 LRU); - * - 列表滚动用 meow_scroll_wrap:跟手 track + 松手 fling + 行高 snap。 + * 浏览语义(M13,用户要求:性能 + 访问放开): + * - 首帧只做**单层**枚举(logic/library.c:一次 opendir/readdir+lstat), + * 不做任何递归「子树含图」探测;列表行 = 当前目录的直接子目录(M11 起还包括 + * .cbz/.zip 档案册,与目录自然序混排); + * - 系统目录/空目录/无图目录都列出且可进入;可一路向上到文件系统 '/', + * 左缘返回由 app.c 逐级上溯、到 '/' 才退出 App; + * - 「直接含图目录=一本书」与档案册页数改为后台懒算:行仍在 PENDING 时副标题「…」, + * 后台线程用 mr_library_probe_path(单层 + mtime/size 失效缓存)算出页数/首图, + * on_tick 补位后只 invalidate 列表视口——首帧/滚动/命中判定都不读子目录/档案; + * - 不可读/不存在的目录:停留在原目录,顶部横幅提示 2.5s。 * - * 绘制/颜色:SDK 注册表非保留绘制,全部取 meow_theme_get() 语义色;主题切换由 app.c 的 - * listener 整帧 invalidate。缩略图缓存只在列表重建时释放(避免池内悬垂指针),上限 24 张。 + * 行绑定(列表 src2 模式):meow_listview_create(..., rows, lib_src, ...) 每帧/每次 + * invalidate 对可见行重新取行;lib_src 只读内存里的 g_lib + 缩略图缓存,并把「待探测/ + * 待缩略图」请求投递到有界队列(不做 IO),因此懒算结果补位后下一帧即可上屏。 + * + * 缩略图:后台线程 meow_img_downscale 到 96px,落磁盘缓存 MeowData//cache + * (BMP,文件名含源 mtime+size,源变化自动失效;≥64 张按 mtime LRU 删除)。 + * 档案册(M11)封面=档内首图:先解压到 page_cache,再按普通图片解码。 + * + * 绘制/颜色全部取 meow_theme_get() 语义色;主题切换由 app.c 的 listener 整帧 invalidate。 */ #include "tuku_internal.h" #include "logic/reader/page_source.h" @@ -37,9 +44,13 @@ #define MR_TOPAPPBAR_H 56 #define MR_DEFAULT_ROW_H 56 #define MR_CACHE_MAX_FILES 64 +#define MR_HINT_H 44 +#define MR_HINT_MS 2500 -#define MR_REQ_MAX 64 -#define MR_RES_MAX 16 +#define MR_TREQ_MAX 64 +#define MR_TRES_MAX 16 +#define MR_PQ_MAX 128 +#define MR_PRES_MAX 48 typedef struct thumb_req { int gen; @@ -53,14 +64,32 @@ typedef struct thumb_res { meow_img *img; } thumb_res; +typedef struct probe_job { + int gen; + int idx; + int is_archive; + char path[MR_PATH_MAX]; +} probe_job; + +typedef struct probe_res { + int gen; + int idx; + int is_archive; + char path[MR_PATH_MAX]; + int ok; + mr_library_probe probe; +} probe_res; + static mr_page_ctrl g_ctrl; static mr_library g_lib; static char g_cur_dir[MR_PATH_MAX] = MR_ROOT_DIR; +static char g_root_dir[MR_PATH_MAX] = MR_ROOT_DIR; /* 「TF卡」回根目标(宿主测试可改) */ 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 int g_tb_home_idx = -1; static struct { int idx; @@ -68,7 +97,7 @@ static struct { } 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; @@ -76,21 +105,41 @@ 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 probe_job g_pq[MR_PQ_MAX]; +static int g_pq_head, g_pq_tail, g_pq_n; +static char g_probe_inflight[MR_PATH_MAX]; -static thumb_res g_res[MR_RES_MAX]; -static int g_res_n; +static probe_res g_pres[MR_PRES_MAX]; +static int g_pres_n; -/* ---- 磁盘缩略图缓存 ---- */ +static thumb_req g_treq[MR_TREQ_MAX]; +static int g_treq_head, g_treq_tail, g_treq_n; +static char g_thumb_inflight[MR_PATH_MAX]; + +static thumb_res g_tres[MR_TRES_MAX]; +static int g_tres_n; + +/* ---- 磁盘缩略图缓存(与解压页缓存同目录) ---- */ static char g_cache_dir[MR_PATH_MAX]; static int g_cache_on; +/* ---- 顶部提示横幅(不可读目录等) ---- */ +static int g_hint_id; +static char g_hint[160]; +static uint64_t g_hint_until; +static int g_hint_x, g_hint_y, g_hint_w, g_hint_h; + static int g_drag; static int g_dragged; static int g_drag_last_y; +/* 书库扫描/后台探测的档案册探测(M11 注入):logic/library 不依赖 zip 容器。 */ +static int cbz_archive_pages(const char *path, void *ud) +{ + (void)ud; + return mr_zip_count_pages(path); +} + /* ============================================================ */ /* 磁盘缓存(BMP 原始缩略图;文件名含源 mtime+size 做失效) */ /* ============================================================ */ @@ -137,10 +186,6 @@ static void cache_prune(void) time_t oldest_t = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { - /* 解压页缓存(M11)与缩略图同目录:mrpage_* 归 page_cache 自己的 - * 64 页/32MiB LRU 管,这里绝不能把它们当缩略图统计或删除——否则 - * 档案页会被缩略图 LRU 清掉(P2-1)。判据与 page_cache 共用。 */ - if (mr_cache_is_page_name(de->d_name)) continue; const char *dot = strrchr(de->d_name, '.'); if (!dot || strcmp(dot, ".bmp") != 0) continue; char full[MR_PATH_MAX + 96]; @@ -256,34 +301,71 @@ static meow_img *thumb_load(const char *cover) return im; } -static void *thumb_worker(void *arg) +static void probe_push_res_locked(const probe_job *job, int ok, const mr_library_probe *p) +{ + if (g_pres_n >= MR_PRES_MAX) return; + probe_res *r = &g_pres[g_pres_n++]; + r->gen = job->gen; + r->idx = job->idx; + r->is_archive = job->is_archive; + snprintf(r->path, sizeof r->path, "%s", job->path); + r->ok = ok; + if (ok && p) r->probe = *p; + else memset(&r->probe, 0, sizeof r->probe); +} + +static void thumb_push_res_locked(const thumb_req *req, meow_img *im) +{ + if (g_tres_n >= MR_TRES_MAX) { meow_img_free(im); return; } + thumb_res *r = &g_tres[g_tres_n++]; + r->gen = req->gen; + r->idx = req->idx; + r->img = im; +} + +static void *lib_worker(void *arg) { (void)arg; for (;;) { - thumb_req req; + int kind = 0; /* 1=探测 2=缩略图 */ + probe_job pj; + thumb_req tr; + pthread_mutex_lock(&g_lock); - while (!g_worker_stop && g_req_n == 0) + while (!g_worker_stop && g_pq_n == 0 && g_treq_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); + if (g_pq_n > 0) { /* 探测优先:页数/角标比重缩略图更影响可读性 */ + kind = 1; + pj = g_pq[g_pq_head]; + g_pq_head = (g_pq_head + 1) % MR_PQ_MAX; + g_pq_n--; + snprintf(g_probe_inflight, sizeof g_probe_inflight, "%s", pj.path); + } else { + kind = 2; + tr = g_treq[g_treq_head]; + g_treq_head = (g_treq_head + 1) % MR_TREQ_MAX; + g_treq_n--; + snprintf(g_thumb_inflight, sizeof g_thumb_inflight, "%s", tr.cover); } pthread_mutex_unlock(&g_lock); + + if (kind == 1) { + mr_library_probe p; + int ok = (mr_library_probe_path(pj.path, pj.is_archive, + cbz_archive_pages, NULL, &p) == 0); + pthread_mutex_lock(&g_lock); + g_probe_inflight[0] = '\0'; + if (!g_worker_stop) probe_push_res_locked(&pj, ok, &p); + pthread_mutex_unlock(&g_lock); + } else { + meow_img *im = thumb_load(tr.cover); + pthread_mutex_lock(&g_lock); + g_thumb_inflight[0] = '\0'; + if (im && !g_worker_stop) thumb_push_res_locked(&tr, im); + else if (im) meow_img_free(im); + pthread_mutex_unlock(&g_lock); + } } return NULL; } @@ -292,7 +374,7 @@ 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; + if (pthread_create(&g_worker, NULL, lib_worker, NULL) == 0) g_worker_on = 1; } static void worker_stop(void) @@ -306,30 +388,57 @@ static void worker_stop(void) 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; + for (int i = 0; i < g_tres_n; i++) meow_img_free(g_tres[i].img); + g_tres_n = 0; + g_pres_n = 0; + g_treq_n = 0; + g_treq_head = g_treq_tail = 0; + g_pq_n = 0; + g_pq_head = g_pq_tail = 0; + g_probe_inflight[0] = '\0'; + g_thumb_inflight[0] = '\0'; pthread_mutex_unlock(&g_lock); } -/* 请求缩略图:bind 只投递,不阻塞首绘/滚动。 */ +/* 探测请求:只投递(有界队列 + 去重),不做 IO。 */ +static void probe_enqueue(int idx, const char *path, int is_archive) +{ + if (!g_worker_on || !path || !path[0]) return; + pthread_mutex_lock(&g_lock); + if (g_worker_stop) { pthread_mutex_unlock(&g_lock); return; } + if (strcmp(g_probe_inflight, path) == 0) { pthread_mutex_unlock(&g_lock); return; } + for (int i = 0; i < g_pq_n; i++) { + int p = (g_pq_head + i) % MR_PQ_MAX; + if (strcmp(g_pq[p].path, path) == 0) { pthread_mutex_unlock(&g_lock); return; } + } + if (g_pq_n >= MR_PQ_MAX) { pthread_mutex_unlock(&g_lock); return; } + g_pq[g_pq_tail].gen = g_gen; + g_pq[g_pq_tail].idx = idx; + g_pq[g_pq_tail].is_archive = is_archive; + snprintf(g_pq[g_pq_tail].path, sizeof g_pq[g_pq_tail].path, "%s", path); + g_pq_tail = (g_pq_tail + 1) % MR_PQ_MAX; + g_pq_n++; + pthread_cond_signal(&g_cv); + 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 (strcmp(g_thumb_inflight, cover) == 0) { pthread_mutex_unlock(&g_lock); return; } + for (int i = 0; i < g_treq_n; i++) { + int p = (g_treq_head + i) % MR_TREQ_MAX; + if (strcmp(g_treq[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++; + if (g_treq_n >= MR_TREQ_MAX) { pthread_mutex_unlock(&g_lock); return; } + g_treq[g_treq_tail].gen = g_gen; + g_treq[g_treq_tail].idx = idx; + snprintf(g_treq[g_treq_tail].cover, sizeof g_treq[g_treq_tail].cover, "%s", cover); + g_treq_tail = (g_treq_tail + 1) % MR_TREQ_MAX; + g_treq_n++; pthread_cond_signal(&g_cv); pthread_mutex_unlock(&g_lock); } @@ -360,38 +469,38 @@ static int thumb_put(int idx, meow_img *im) } /* ============================================================ */ -/* 列表虚拟化数据源 */ +/* 行数据源(src2:每帧按需取可见行;零 IO,懒算请求只入队) */ /* ============================================================ */ -static int lib_count(void *ctx) -{ - (void)ctx; - return g_lib.count; -} - -static void lib_bind(int idx, meow_lv_item *item, void *ctx) +static int lib_src(int idx, meow_list_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); + mr_library_row row; + if (!mr_library_row_at(&g_lib, idx, &row)) return 0; + item->title = row.title; + item->subtitle = row.subtitle; + item->enabled = row.disabled ? 0 : 1; + if (row.disabled) return 1; + + int i = row.index; + mr_library_entry *e = &g_lib.items[i]; + item->right = row.is_book ? MEOW_LIST_RIGHT_NONE : MEOW_LIST_RIGHT_ARROW; + + meow_img *im = thumb_find(i); 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; + item->icon_kind = row.is_book ? MEOW_LIST_ICON_FILE : MEOW_LIST_ICON_FOLDER; /* 文件夹册用缩略图源图;档案册没有图片文件,用档案路径(worker 解压首图)。 */ const char *src = NULL; if (e->cover[0]) src = e->cover; - else if (e->has_images && mr_path_is_archive(e->path)) src = e->path; - if (src) thumb_enqueue(idx, src); + else if (row.is_book && row.is_archive) src = e->path; + if (src) thumb_enqueue(i, src); } + if (e->probe_state == MR_LIB_PENDING) probe_enqueue(i, e->path, e->is_archive); + return 1; } /* ============================================================ */ @@ -402,6 +511,14 @@ static int lib_top_y(void) return meow_ui_statusbar_height() + MR_TOPAPPBAR_H; } +static void hint_place(void) +{ + g_hint_x = 0; + g_hint_y = lib_top_y(); + g_hint_w = meow_ui_width(); + g_hint_h = MR_HINT_H; +} + static void list_rebuild(void) { if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; } @@ -412,16 +529,16 @@ static void list_rebuild(void) 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); + int rows = g_lib.count + (g_lib.truncated ? 1 : 0); + g_lv = meow_listview_create(g_list_x, g_list_y, g_list_w, g_list_h, rows, + lib_src, 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); /* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式: * 每行 surfaceContainer 圆角卡 + 行间空隙 + 卡内 tonal 选中。 */ meow_listview_set_container(g_lv, 1); - meow_listview_set_empty_text(g_lv, "No folders or CBZ books"); + meow_listview_set_empty_text(g_lv, "空目录(无可进入的子目录)"); g_scroll = meow_scroll_wrap(g_lv, 1); if (g_scroll) meow_scroll_set_snap(g_scroll, MR_DEFAULT_ROW_H); @@ -431,12 +548,15 @@ static void update_topbar(void) { if (!g_tb) return; const char *title = "图库"; - if (strcmp(g_cur_dir, MR_ROOT_DIR) != 0) { + if (strcmp(g_cur_dir, "/") == 0) { + title = "文件系统 /"; + } else if (strcmp(g_cur_dir, g_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); + /* 可一路向上到文件系统 '/':到 '/' 才隐藏返回(此时左缘返回=退出 App)。 */ + meow_topappbar_set_back(g_tb, strcmp(g_cur_dir, "/") != 0); } static void list_invalidate(void) @@ -444,61 +564,179 @@ static void list_invalidate(void) meow_ui_invalidate(g_list_x, g_list_y, g_list_w, g_list_h); } -/* 目录切换:作废旧异步结果、释放内存缩略图、重新扫描、重建列表(新池强制重新 bind)。 */ +static void hint_invalidate(void) +{ + meow_ui_invalidate(g_hint_x, g_hint_y, g_hint_w, g_hint_h); +} + +static void show_hint(const char *msg) +{ + snprintf(g_hint, sizeof g_hint, "%s", msg ? msg : ""); + g_hint_until = meow_ui_time_ms() + MR_HINT_MS; + hint_place(); + hint_invalidate(); +} + +/* 顶部横幅绘制物:只在提示有效且本页 active 时输出(custom 绘制物无 owner 闸门,必须自守)。 */ +static void library_draw_hint(void *ctx) +{ + (void)ctx; + if (meow_ui_activity_active() != ACT_LIBRARY) return; + if (!g_hint[0]) return; + + const meow_theme *th = meow_theme_get(); + int x = g_hint_x, y = g_hint_y, w = g_hint_w, h = g_hint_h; + if (w < 40 || h < 20) return; + meow_ui_fill_rect(x + 8, y + 4, w - 16, h - 8, th->surfaceContainerHighest); + meow_ui_draw_rect(x + 8, y + 4, w - 16, h - 8, th->error); + int ty = y + (h - meow_ui_text_height(th->font_body)) / 2; + meow_ui_text(x + 20, ty, g_hint, th->font_body, th->error); +} + +/* 目录切换:作废旧异步结果、释放内存缩略图、重建列表(新池强制重新取行)。 */ 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; + g_pq_n = 0; + g_pq_head = g_pq_tail = 0; + g_treq_n = 0; + g_treq_head = g_treq_tail = 0; + g_pres_n = 0; + for (int i = 0; i < g_tres_n; i++) meow_img_free(g_tres[i].img); + g_tres_n = 0; + g_probe_inflight[0] = '\0'; + g_thumb_inflight[0] = '\0'; pthread_mutex_unlock(&g_lock); } -/* 书库扫描的档案册探测(M11 注入):logic/library 不依赖 zip 容器。 */ -static int cbz_archive_pages(const char *path, void *ud) +/* 目录切换:成功 1;不可读/不存在 0(提示;首次加载时仍建空列表)。 */ +static int load_dir(const char *dir) { - (void)ud; - return mr_zip_count_pages(path); -} + /* dir 可能指向 g_lib 里的行路径(点击进入子目录):先拷到本地, + * 避免切换后 mr_library_free 释放 g_lib,dir 变成悬垂指针。 */ + char target[MR_PATH_MAX]; + if (!dir || !dir[0] || strlen(dir) >= sizeof target) { + show_hint("路径无效"); + return 0; + } + snprintf(target, sizeof target, "%s", dir); + + mr_library next; + memset(&next, 0, sizeof next); + + uint64_t t0 = meow_ui_time_ms(); + int n = mr_library_scan_ex(&next, target, MR_LIB_MAX_DEPTH, + cbz_archive_pages, NULL); + uint64_t ms = meow_ui_time_ms() - t0; + if (n < 0) { + mr_library_free(&next); + const char *slash = strrchr(target, '/'); + const char *name = (slash && slash[1]) ? slash + 1 : target; + char msg[200]; + snprintf(msg, sizeof msg, "无法读取目录:%s", name); + show_hint(msg); + meow_log(MEOW_LOG_WARN, "library scan failed: %s", target); + /* 启动首帧根目录不可读:仍建一个空列表,页面可交互(返回/回根/上溯)。 */ + if (!g_lv) { + mr_library_free(&g_lib); + snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target); + reset_async(); + thumbs_free(); + list_rebuild(); + update_topbar(); + } + return 0; + } + if (ms > 120) + meow_log(MEOW_LOG_WARN, "library scan slow: %s %llums (%d rows)", + target, (unsigned long long)ms, n); + + mr_library_free(&g_lib); + g_lib = next; + snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target); -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_ex(&g_lib, g_cur_dir, MR_LIB_MAX_DEPTH, - cbz_archive_pages, NULL) < 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()); + return 1; } int library_at_root(void) { - return strcmp(g_cur_dir, MR_ROOT_DIR) == 0; + return strcmp(g_cur_dir, g_root_dir) == 0; } int library_go_up(void) { - if (library_at_root()) return 0; + if (strcmp(g_cur_dir, "/") == 0) return 0; /* 到文件系统根:交给 app.c 退出 */ 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); + if (!load_dir(parent)) { + /* 父目录不可读 → 提示并停留。特例:父目录是文件系统 "/" 时仍切到 "/" 的空列表, + * 让下一次返回=退出 App(Android 沙箱可能读不了 "/",否则会卡在最后一个可读目录)。 */ + if (strcmp(parent, "/") == 0) { + mr_library_free(&g_lib); + snprintf(g_cur_dir, sizeof g_cur_dir, "%s", parent); + reset_async(); + thumbs_free(); + list_rebuild(); + update_topbar(); + if (g_ctrl.active) + meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); + } + return 1; + } return 1; } +/* TopAppBar「TF卡」:回到书库根(已在根则回到列表顶部)。 */ +static void library_go_tf_root(void) +{ + if (strcmp(g_cur_dir, g_root_dir) == 0) { + if (g_scroll) meow_scroll_stop(g_scroll); + if (g_lv) meow_listview_set_scroll(g_lv, 0); + list_invalidate(); + return; + } + load_dir(g_root_dir); +} + +/* 点击行:先确保懒判定完成(单层探测,通常已在缓存里),书=进阅读器,否则进目录。 */ +static void library_open_entry(int idx) +{ + if (idx < 0 || idx >= g_lib.count) return; + mr_library_entry *e = &g_lib.items[idx]; + + if (e->probe_state == MR_LIB_PENDING) { + mr_library_probe p; + if (mr_library_probe_path(e->path, e->is_archive, cbz_archive_pages, NULL, &p) == 0) + mr_library_apply_probe(&g_lib, idx, &p); + else + mr_library_mark_error(&g_lib, idx); + list_invalidate(); + } + if (e->probe_state == MR_LIB_FAILED) { + char msg[200]; + snprintf(msg, sizeof msg, "无法读取:%s", e->name); + show_hint(msg); + return; + } + if (e->has_images == 1) { mr_reader_open_dir(e->path); return; } + if (e->is_archive) { /* 档案册但没有图片页(坏档/空档):不导航 */ + char msg[200]; + snprintf(msg, sizeof msg, "无法阅读(档内没有图片页):%s", e->name); + show_hint(msg); + return; + } + load_dir(e->path); +} + /* ============================================================ */ /* 事件 */ /* ============================================================ */ @@ -511,14 +749,15 @@ void library_on_tap(int x, int y) if (g_tb) { int hit = meow_topappbar_hit(g_tb, x, y); if (hit == MEOW_TOPBAR_BACK) { library_go_up(); return; } + if (g_tb_home_idx >= 0 && hit == g_tb_home_idx) { library_go_tf_root(); 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); + if (idx < 0) return; + mr_library_row row; + if (!mr_library_row_at(&g_lib, idx, &row) || row.disabled) return; + library_open_entry(row.index); } void library_on_swipe(int dir, int dist) @@ -567,19 +806,47 @@ void library_on_touch_move(int x, int y) list_invalidate(); } -/* 16ms tick:收下后台解码好的缩略图并局部补位。 */ +/* 16ms tick:收下后台探测/缩略图结果并局部补位;提示横幅到期自动清除。 */ void library_on_tick(void) { if (!g_ctrl.active) return; - thumb_res got[MR_RES_MAX]; - int n = 0; + + if (g_hint[0] && meow_ui_time_ms() >= g_hint_until) { + g_hint[0] = '\0'; + hint_invalidate(); + } + + probe_res pres[MR_PRES_MAX]; + int pn = 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; + pn = g_pres_n; + for (int i = 0; i < pn; i++) pres[i] = g_pres[i]; + g_pres_n = 0; pthread_mutex_unlock(&g_lock); int changed = 0; + for (int i = 0; i < pn; i++) { + probe_res *r = &pres[i]; + if (r->gen != g_gen || r->idx < 0 || r->idx >= g_lib.count) continue; + mr_library_entry *e = &g_lib.items[r->idx]; + if (strcmp(e->path, r->path) != 0) continue; + if (r->ok) { + if (mr_library_apply_probe(&g_lib, r->idx, &r->probe) && e->cover[0]) + thumb_enqueue(r->idx, e->cover); + } else { + mr_library_mark_error(&g_lib, r->idx); + } + changed = 1; + } + + thumb_res got[MR_TRES_MAX]; + int n = 0; + pthread_mutex_lock(&g_lock); + n = g_tres_n; + for (int i = 0; i < n; i++) got[i] = g_tres[i]; + g_tres_n = 0; + pthread_mutex_unlock(&g_lock); + 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)) @@ -605,10 +872,15 @@ static void library_page_create(meow_view *v, void *ctx) int sb = meow_ui_statusbar_height(); g_tb = meow_topappbar_create(0, sb, w, MR_TOPAPPBAR_H, "图库"); if (g_tb) { + g_tb_home_idx = meow_topappbar_add_action(g_tb, "TF卡"); 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); + + hint_place(); + g_hint_id = meow_draw_custom_register(MEOW_Z_TOP, g_hint_x, g_hint_y, + g_hint_w, g_hint_h, library_draw_hint, NULL); + load_dir(g_root_dir); } static void library_page_enter(meow_view *v, void *ctx) @@ -632,6 +904,7 @@ static void library_page_enter(meow_view *v, void *ctx) g_list_w = meow_ui_width(); g_list_h = h; } + hint_place(); g_drag = 0; g_dragged = 0; update_topbar(); @@ -652,11 +925,13 @@ static void library_page_destroy(meow_view *v, void *ctx) { (void)v; (void)ctx; worker_stop(); + if (g_hint_id) { meow_draw_unregister(g_hint_id); g_hint_id = 0; } 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); + mr_library_probe_cache_clear(); } meow_view *library_view_create(void) @@ -669,3 +944,62 @@ meow_view *library_view_create(void) }; return meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); } + +#ifdef MR_LIBRARY_UI_TEST_HOOKS +/* 宿主集成测试接缝(只在 -DMR_LIBRARY_UI_TEST_HOOKS 编译时存在;不进交付二进制)。 */ +int library_test_load(const char *dir) +{ + return load_dir(dir); +} + +void library_test_set_root(const char *root) +{ + if (root && root[0]) snprintf(g_root_dir, sizeof g_root_dir, "%s", root); +} + +const char *library_test_root(void) +{ + return g_root_dir; +} + +const char *library_test_dir(void) +{ + return g_cur_dir; +} + +void library_test_set_dir(const char *dir) +{ + if (dir && dir[0]) snprintf(g_cur_dir, sizeof g_cur_dir, "%s", dir); +} + +int library_test_rows(void) +{ + return g_lib.count + (g_lib.truncated ? 1 : 0); +} + +int library_test_src(int idx, meow_list_item *item) +{ + return lib_src(idx, item, NULL); +} + +int library_test_hint_active(void) +{ + return g_hint[0] != '\0'; +} + +const char *library_test_hint(void) +{ + return g_hint; +} + +int library_test_go_root(void) +{ + library_go_tf_root(); + return strcmp(g_cur_dir, g_root_dir) == 0; +} + +int library_test_go_up(void) +{ + return library_go_up(); +} +#endif