diff --git a/tuku/logic/library.c b/tuku/logic/library.c index 38a5d40..b640401 100644 --- a/tuku/logic/library.c +++ b/tuku/logic/library.c @@ -7,8 +7,14 @@ * 收进结果并自然排序——因此根视图不会列出后代条目,进入子目录时再重新扫描该层。 * * 内存:条目数组倍增分配;路径/名称定长,随 mr_library_free() 释放。 + * + * M7 追加(不改扫描语义): + * - 续读角标:含图册的副标题后缀「· 续读 Pn」(读 logic/reader 的每册 last page); + * - 册间衔接:mr_book_adjacent 给出同父目录下按书库自然序的相邻「含图册」, + * 供阅读器末页前进/首页后退跨册;mr_library_dir_has_images 是共享的「是不是一本书」判据。 */ #include "library.h" +#include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */ #include #include @@ -215,6 +221,17 @@ int mr_library_scan(mr_library *lib, const char *dir, int max_depth) 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,副标题保持原样。 */ + if (e.has_images) { + char badge[24]; + if (mr_cont_badge(e.path, badge, (int)sizeof badge)) { + 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); + } + } if (lib_add(lib, &e) != 0) { closedir(d); mr_library_free(lib); @@ -245,3 +262,101 @@ int mr_path_parent(const char *path, char *out, int outsz) out[n] = '\0'; return 0; } + +/* ============================================================ */ +/* 册间衔接(M7) */ +/* ============================================================ */ +int mr_library_dir_has_images(const char *dir) +{ + if (!dir || !dir[0]) return 0; + DIR *d = opendir(dir); + if (!d) return 0; + + int found = 0; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *name = de->d_name; + if (name[0] == '.' || !mr_path_is_image(name)) continue; + char full[MR_PATH_MAX]; + if (join_path(full, (int)sizeof full, dir, name) != 0) continue; + struct stat st; + if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; + found = 1; + break; + } + closedir(d); + return found; +} + +static int name_ptr_cmp(const void *pa, const void *pb) +{ + const char *a = *(const char *const *)pa; + const char *b = *(const char *const *)pb; + return mr_natural_cmp(a, b); +} + +int mr_book_adjacent(const char *dir, int delta, char *out, int outsz) +{ + if (!out || outsz <= 0) return -1; + out[0] = '\0'; + if (!dir || !dir[0] || (delta != 1 && delta != -1)) return -1; + + char norm[MR_PATH_MAX]; + size_t dl = strlen(dir); + while (dl > 1 && dir[dl - 1] == '/') dl--; + if (dl >= sizeof norm) return -1; + memcpy(norm, dir, dl); + norm[dl] = '\0'; + + const char *slash = strrchr(norm, '/'); + if (!slash || !slash[1]) return -1; + const char *cur = slash + 1; + + char parent[MR_PATH_MAX]; + if (mr_path_parent(norm, parent, (int)sizeof parent) != 0) return -1; + + DIR *d = opendir(parent); + if (!d) return 0; + + char **names = NULL; + int n = 0, cap = 0; + struct dirent *de; + while ((de = readdir(d)) != NULL) { + const char *name = de->d_name; + if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue; + if (ignored_dir_name(name)) continue; + + char full[MR_PATH_MAX]; + if (join_path(full, (int)sizeof full, parent, name) != 0) continue; + struct stat st; + if (lstat(full, &st) != 0 || S_ISLNK(st.st_mode) || !S_ISDIR(st.st_mode)) continue; + if (!mr_library_dir_has_images(full)) continue; /* 只算「一本书」,中间目录跳过 */ + + if (n >= cap) { + int nc = cap ? cap * 2 : 16; + char **nn = (char **)realloc(names, (size_t)nc * sizeof *nn); + if (!nn) break; + names = nn; + cap = nc; + } + size_t len = strlen(name); + names[n] = (char *)malloc(len + 1); + if (!names[n]) break; + memcpy(names[n], name, len + 1); + n++; + } + closedir(d); + + int rc = 0; + if (n > 1) qsort(names, (size_t)n, sizeof *names, name_ptr_cmp); + int idx = -1; + for (int i = 0; i < n; i++) + if (strcmp(names[i], cur) == 0) { idx = i; break; } + int target = idx + delta; + if (idx >= 0 && target >= 0 && target < n) { + if (join_path(out, outsz, parent, names[target]) == 0) rc = 1; + } + for (int i = 0; i < n; i++) free(names[i]); + free(names); + return rc; +} diff --git a/tuku/logic/library.h b/tuku/logic/library.h index e97d7a6..e21268c 100644 --- a/tuku/logic/library.h +++ b/tuku/logic/library.h @@ -1,6 +1,8 @@ /* logic/library.h — 漫画书库扫描/排序(纯 C,不依赖 meow_ui.h)。 * * 分层契约同 examples/hello:logic 层只含文件系统与纯数据,宿主 gcc 可直接裸测。 + * (M7 起另提供每册续读角标与册间顺序查询;续读状态本身由 logic/reader.c 持久化, + * 本层只读它来拼副标题,宿主测试用配置存根顶替。) * 书库语义: * - 从 TF 卡根(MR_ROOT_DIR)起列「直接子目录」; * - 只保留自身含 PNG/JPEG/BMP 的目录,或子树中最终含图的中间目录(可继续导航); @@ -57,6 +59,16 @@ void mr_library_free(mr_library *lib); * 成功 0;参数非法/输出缓冲不足 -1。 */ int mr_path_parent(const char *path, char *out, int outsz); +/* 目录是否**直接**含受支持图片(= 书库语义里的「一本书」,可直接进 reader)。 + * 1=有;0=没有/不可读。 */ +int mr_library_dir_has_images(const char *dir); + +/* 册间衔接(M7):同父目录下按书库自然序排列的「直接含图」兄弟册。 + * delta=+1 下一册 / -1 上一册。1=找到并写入 out 完整路径; + * 0=没有相邻册(当前册是首/末册,或当前册不在兄弟表中);-1=参数非法。 + * 语义与书库一致:跳过隐藏/系统目录与符号链接,排序同 mr_natural_cmp。 */ +int mr_book_adjacent(const char *dir, int delta, char *out, int outsz); + #ifdef __cplusplus } #endif diff --git a/tuku/logic/reader.c b/tuku/logic/reader.c index 9945090..44d633c 100644 --- a/tuku/logic/reader.c +++ b/tuku/logic/reader.c @@ -1,10 +1,12 @@ -/* logic/reader.c — 阅读方向状态(默认 RTL;M4/M6 的阅读器直接复用)。 +/* logic/reader.c — 阅读方向状态(默认 RTL;M4/M6 的阅读器直接复用)+ 每册续读状态(M7)。 * * M5 评审 P1 修复:方向设置要「下次启动生效」。最小持久化(不新增任何方向状态): * - mr_reader_set_direction 成功切换后 best-effort 写 meow_config("reader.dir","rtl"/"ltr"); * 写失败只打 WARN,不阻断切换(本次运行仍生效); * - mr_reader_get_direction 首次调用惰性读一次配置恢复;无配置/值非法 → 默认 RTL; * 之后走纯内存快路径(不再读配置)。 + * M7:阅读模式(翻页/条漫)与每册 last page 同为 meow_config 小键(reader.mode/page.<册标识>, + * 册标识=规范化目录路径的 FNV-1a64);书库行角标经 mr_cont_badge 读取同一份状态。 * 依赖面:只 include 配置/日志两个模块头(不含 UI);宿主测试用 * test/settings_reader_stub.c 顶替 meow_config_* 与 meow_log。 */ @@ -12,6 +14,8 @@ #include "include/meow_ui_config.h" #include "include/meow_ui_log.h" +#include +#include #include #define MR_DIR_CFG_KEY "reader.dir" @@ -59,3 +63,122 @@ void mr_reader_test_reset(void) g_restored = 0; } #endif + +/* ============================================================ */ +/* 阅读模式 + 每册续读状态(M7) */ +/* ============================================================ */ +int mr_mode_valid(int mode) +{ + return mode == MR_MODE_PAGED || mode == MR_MODE_WEBTOON; +} + +const char *mr_mode_name(int mode) +{ + return mode == MR_MODE_WEBTOON ? "webtoon" : "paged"; +} + +int mr_mode_parse(const char *s) +{ + if (!s) return -1; + if (strcmp(s, "paged") == 0) return MR_MODE_PAGED; + if (strcmp(s, "webtoon") == 0) return MR_MODE_WEBTOON; + return -1; +} + +/* 目录规范化:去掉末尾 '/'(根 "/" 保留),保证同一册跨调用/跨启动得到同一键。 */ +static void cont_norm_dir(const char *dir, char *out, int outsz) +{ + if (!out || outsz <= 0) return; + out[0] = '\0'; + if (!dir || !dir[0]) return; + int n = (int)strlen(dir); + while (n > 1 && dir[n - 1] == '/') n--; + if (n >= outsz) n = outsz - 1; + memcpy(out, dir, (size_t)n); + out[n] = '\0'; +} + +static unsigned long long cont_fnv1a64(const char *s) +{ + unsigned long long h = 1469598103934665603ULL; + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + h ^= (unsigned long long)*p; + h *= 1099511628211ULL; + } + return h; +} + +int mr_cont_key(const char *dir, const char *field, char *out, int outsz) +{ + if (!out || outsz <= 0) return -1; + out[0] = '\0'; + if (!dir || !dir[0] || !field || !field[0]) return -1; + char norm[MR_CONT_PATH_MAX]; + cont_norm_dir(dir, norm, (int)sizeof norm); + if (!norm[0]) return -1; + int n = snprintf(out, (size_t)outsz, "reader.%s.%016llx", field, cont_fnv1a64(norm)); + if (n <= 0 || n >= outsz) { out[0] = '\0'; return -1; } + return 0; +} + +int mr_cont_get_page(const char *dir, int def) +{ + char key[64]; + char buf[24]; + if (mr_cont_key(dir, "page", key, (int)sizeof key) != 0) return def; + if (meow_config_get(key, buf, (int)sizeof buf) != MEOW_CFG_OK) return def; + char *end = NULL; + long v = strtol(buf, &end, 10); + if (end == buf || (end && *end != '\0')) return def; + if (v < 0 || v > MR_CONT_PAGE_MAX) return def; + return (int)v; +} + +void mr_cont_set_page(const char *dir, int page) +{ + char key[64]; + char val[24]; + if (page < 0) page = 0; + if (page > MR_CONT_PAGE_MAX) page = MR_CONT_PAGE_MAX; + if (mr_cont_key(dir, "page", key, (int)sizeof key) != 0) return; + snprintf(val, sizeof val, "%d", page); + if (meow_config_set(key, val) != MEOW_CFG_OK) + meow_log(MEOW_LOG_WARN, "reader: save page failed (best-effort)"); +} + +int mr_cont_get_mode(const char *dir, int def) +{ + char key[64]; + char buf[16]; + if (!mr_mode_valid(def)) def = MR_MODE_PAGED; + if (mr_cont_key(dir, "mode", key, (int)sizeof key) != 0) return def; + if (meow_config_get(key, buf, (int)sizeof buf) != MEOW_CFG_OK) return def; + int m = mr_mode_parse(buf); + return m >= 0 ? m : def; +} + +void mr_cont_set_mode(const char *dir, int mode) +{ + char key[64]; + if (!mr_mode_valid(mode)) return; + if (mr_cont_key(dir, "mode", key, (int)sizeof key) != 0) return; + if (meow_config_set(key, mr_mode_name(mode)) != MEOW_CFG_OK) + meow_log(MEOW_LOG_WARN, "reader: save mode failed (best-effort)"); +} + +void mr_cont_save(const char *dir, int page, int mode) +{ + if (!dir || !dir[0]) return; + mr_cont_set_page(dir, page); + mr_cont_set_mode(dir, mode); +} + +int mr_cont_badge(const char *dir, char *out, int outsz) +{ + if (!out || outsz <= 0) return 0; + out[0] = '\0'; + int page = mr_cont_get_page(dir, -1); + if (page <= 0) return 0; + snprintf(out, (size_t)outsz, "续读 P%d", page + 1); + return 1; +} diff --git a/tuku/logic/reader.h b/tuku/logic/reader.h index 5571cdd..fd62fcc 100644 --- a/tuku/logic/reader.h +++ b/tuku/logic/reader.h @@ -1,6 +1,7 @@ -/* logic/reader.h — 阅读器纯逻辑接缝(M4 填充实现,M3 先落状态 API)。 +/* logic/reader.h — 阅读器纯逻辑接缝(M4 填充实现,M3 先落状态 API;M7 追加模式与每册续读状态)。 * * 阅读方向默认 RTL(日式漫画从右往左翻);设置页/阅读器可切 LTR。 + * 阅读模式(翻页/条漫)与每册 last page 走 meow_config 持久化,键含册标识。 * 本模块保持纯 C(不 include meow_ui.h),宿主可直接测。 */ #ifndef MR_READER_H @@ -17,6 +18,36 @@ int mr_reader_get_direction(void); /* 只接受 MR_DIR_RTL / MR_DIR_LTR;其它值忽略(保持原方向)。 */ void mr_reader_set_direction(int dir); +/* ============================================================ */ +/* 阅读模式(M7):左右翻页 / 条漫竖向连续滚动 */ +/* ============================================================ */ +#define MR_MODE_PAGED 0 /* 左右翻页(默认) */ +#define MR_MODE_WEBTOON 1 /* 条漫:垂直连续滚动 */ + +int mr_mode_valid(int mode); +const char *mr_mode_name(int mode); /* "paged" / "webtoon"(配置值;非法按 paged) */ +int mr_mode_parse(const char *s); /* -1=未知 */ + +/* ============================================================ */ +/* 每册续读状态(last page + mode,meow_config 持久化) */ +/* ------------------------------------------------------------ + * 键 = reader..:同一册(同一目录)跨启动恢复, + * 不同册互不影响;目录改名/移动视为新册。写失败只 WARN(best-effort,不阻断阅读)。 + * 依赖:只 include 配置/日志两个模块头,宿主测试用 settings_reader_stub 顶替。 + * ============================================================ */ +#define MR_CONT_PATH_MAX 512 /* 册路径规范化的本地缓冲(同 MR_PATH_MAX 口径) */ +#define MR_CONT_PAGE_MAX 1000000 /* 配置里 last page 的合理上限(防坏值) */ + +int mr_cont_key(const char *dir, const char *field, char *out, int outsz); +int mr_cont_get_mode(const char *dir, int def); +void mr_cont_set_mode(const char *dir, int mode); +int mr_cont_get_page(const char *dir, int def); +void mr_cont_set_page(const char *dir, int page); +/* 一次写 page+mode(尽最大努力,两键分别尝试)。 */ +void mr_cont_save(const char *dir, int page, int mode); +/* 书库行续读角标:1=写入 "续读 P5"(page>0 才有记录);0=无。 */ +int mr_cont_badge(const char *dir, char *out, int outsz); + #ifdef __cplusplus } #endif diff --git a/tuku/logic/reader/nav.c b/tuku/logic/reader/nav.c index fb7d29d..4743e6f 100644 --- a/tuku/logic/reader/nav.c +++ b/tuku/logic/reader/nav.c @@ -1,5 +1,8 @@ /* logic/reader/nav.c — 阅读导航纯逻辑实现(见 nav.h 的方向语义说明)。 */ #include "logic/reader/nav.h" +#include "logic/reader/page_source.h" /* MR_BOOK_MAX_PAGES(条漫排版页数上限) */ + +#include int mr_nav_tap_zone(int x, int view_w) { @@ -118,3 +121,209 @@ int mr_nav_fit_next(int fit) if (fit < 0 || fit >= MR_FIT_COUNT) return MR_FIT_WIDTH; return (fit + 1) % MR_FIT_COUNT; } + +/* ============================================================ */ +/* 条漫排版 / 滚动窗口 */ +/* ============================================================ */ +static int strip_page_height(int src_w, int src_h, int vw) +{ + long long h; + if (vw <= 0) vw = 1; + if (src_w > 0 && src_h > 0) + h = (long long)src_h * vw / src_w; /* 适宽等比 */ + else + h = (long long)vw * 3 / 2; /* 未知尺寸:2:3 竖图占位 */ + if (h < 1) h = 1; + if (h > MR_STRIP_MAX_PAGE_H) h = MR_STRIP_MAX_PAGE_H; + return (int)h; +} + +void mr_strip_free(mr_strip *s) +{ + if (!s) return; + free(s->top); + free(s->height); + s->top = NULL; + s->height = NULL; + s->count = s->total = 0; + s->vw = s->vh = 0; +} + +int mr_strip_build(mr_strip *s, int count, int vw, int vh, + mr_strip_meta_fn meta, void *ctx) +{ + if (!s) return -1; + mr_strip_free(s); + if (count <= 0 || count > MR_BOOK_MAX_PAGES || vw <= 0 || vh <= 0) return -1; + + s->top = (int *)calloc((size_t)count + 1, sizeof *s->top); + s->height = (int *)calloc((size_t)count, sizeof *s->height); + if (!s->top || !s->height) { mr_strip_free(s); return -1; } + + s->count = count; + s->vw = vw; + s->vh = vh; + long long y = 0; + for (int i = 0; i < count; i++) { + int sw = 0, sh = 0; + if (meta && !meta(ctx, i, &sw, &sh)) { sw = sh = 0; } /* 未知/失败 → 占位 */ + int h = strip_page_height(sw, sh, vw); + s->height[i] = h; + s->top[i] = (int)y; + y += h; + if (y > 1000000000LL) { /* 累计偏移上限(防溢出;正常书远达不到) */ + y = 1000000000LL; + for (int k = i + 1; k < count; k++) { + s->height[k] = 1; + s->top[k] = (int)y; + y += 1; + if (y > 1000000000LL) y = 1000000000LL; + } + s->top[count] = (int)y; + s->total = (int)y; + return 0; + } + } + s->top[count] = (int)y; + s->total = (int)y; + return 0; +} + +int mr_strip_page_h(const mr_strip *s, int index) +{ + if (!s || !s->height || index < 0 || index >= s->count) return 0; + return s->height[index]; +} + +int mr_strip_top(const mr_strip *s, int index) +{ + if (!s || !s->top || index < 0 || index > s->count) return 0; + return s->top[index]; +} + +int mr_strip_total(const mr_strip *s) +{ + return s ? s->total : 0; +} + +int mr_strip_max_offset(const mr_strip *s) +{ + if (!s || s->count <= 0) return 0; + int m = s->total - s->vh; + return m > 0 ? m : 0; +} + +int mr_strip_page_at(const mr_strip *s, int off) +{ + if (!s || s->count <= 0 || !s->top) return -1; + if (off <= 0) return 0; + if (off >= s->total) return s->count - 1; + int lo = 0, hi = s->count - 1; + while (lo < hi) { /* 最大的 top[i] <= off */ + int mid = lo + (hi - lo + 1) / 2; + if (s->top[mid] <= off) lo = mid; + else hi = mid - 1; + } + return lo; +} + +int mr_strip_offset_for_page(const mr_strip *s, int page) +{ + if (!s || s->count <= 0) return -1; + if (page < 0) page = 0; + if (page >= s->count) page = s->count - 1; + int off = s->top[page]; + int m = mr_strip_max_offset(s); + if (off > m) off = m; + if (off < 0) off = 0; + return off; +} + +int mr_strip_window(const mr_strip *s, int off, int max_pages, + int *first, int *last) +{ + if (first) *first = 0; + if (last) *last = -1; + if (!s || s->count <= 0) return 0; + if (max_pages <= 0) max_pages = 1; + + int m = mr_strip_max_offset(s); + if (off < 0) off = 0; + if (off > m) off = m; + + int f = mr_strip_page_at(s, off); + int l = mr_strip_page_at(s, off + s->vh - 1); + if (l < f) l = f; + + int lo = f - 1; /* 视口 ±1 页 */ + int hi = l + 1; + if (lo < 0) lo = 0; + if (hi > s->count - 1) hi = s->count - 1; + if (hi - lo + 1 > max_pages) { /* 驻留上限:从视口中心向外截 */ + int center = (f + l) / 2; + lo = center - (max_pages - 1) / 2; + hi = lo + max_pages - 1; + if (lo < 0) { lo = 0; hi = max_pages - 1; } + if (hi > s->count - 1) { hi = s->count - 1; lo = hi - max_pages + 1; } + if (lo < 0) lo = 0; + } + if (first) *first = lo; + if (last) *last = hi; + return hi - lo + 1; +} + +int mr_strip_at_end(const mr_strip *s, int off) +{ + if (!s || s->count <= 0) return 0; + return off >= mr_strip_max_offset(s); +} + +int mr_strip_at_start(const mr_strip *s, int off) +{ + if (!s || s->count <= 0) return 0; + return off <= 0; +} + +/* ============================================================ */ +/* 跳页状态 */ +/* ============================================================ */ +void mr_jump_cancel(mr_jump *j) +{ + if (!j) return; + j->pending = 0; + j->target = -1; + j->t0 = 0; +} + +void mr_jump_begin(mr_jump *j, int page, int count, unsigned long long now) +{ + if (!j) return; + if (count <= 0 || page < 0 || page >= count) { mr_jump_cancel(j); return; } + j->pending = 1; + j->target = page; + j->t0 = now; +} + +int mr_jump_active(const mr_jump *j) +{ + return j && j->pending ? 1 : 0; +} + +int mr_jump_target(const mr_jump *j) +{ + return (j && j->pending) ? j->target : -1; +} + +int mr_jump_poll(mr_jump *j, int have_img, unsigned long long now, + unsigned long long timeout_ms, int *timed_out) +{ + if (timed_out) *timed_out = 0; + if (!j || !j->pending) return 0; + if (have_img) { mr_jump_cancel(j); return 1; } + if (timeout_ms > 0 && now - j->t0 >= timeout_ms) { + mr_jump_cancel(j); + if (timed_out) *timed_out = 1; + return 1; + } + return 0; +} diff --git a/tuku/logic/reader/nav.h b/tuku/logic/reader/nav.h index 4d328f3..a74b193 100644 --- a/tuku/logic/reader/nav.h +++ b/tuku/logic/reader/nav.h @@ -64,6 +64,65 @@ int mr_nav_slide_from(int rtl, int delta, int view_w); /* fit 档循环:适宽 → 适高 → 原始 → 适宽。 */ int mr_nav_fit_next(int fit); +/* ============================================================ */ +/* 条漫(M7):竖向连续滚动的排版 / 滚动窗口 / 跳页状态 */ +/* ------------------------------------------------------------ + * 排版用页源契约的 meta()(原始像素尺寸,不解码):每页按屏宽等比换算显示高度, + * 累加成「整条」的 y 偏移表。未知尺寸的页按 2:3 竖图占位(排版稳定,解码后不改布局)。 + * 全部纯整数运算、无 SDK 依赖;reader.c 与宿主测试共用(logic/reader/nav.c)。 + * ============================================================ */ +#define MR_STRIP_MAX_PAGE_H 200000 /* 单页显示高度上限(防坏头/极端长图撑爆累计偏移) */ + +typedef struct mr_strip { + int count; + int vw, vh; + int *top; /* count+1 个:top[i]=第 i 页顶端偏移;top[count]=整条总高 */ + int *height; /* count 个:每页显示高度(>=1) */ + int total; +} mr_strip; + +/* 页尺寸回调(通常是 mr_book_meta):1=已知原始尺寸,0=未知。 */ +typedef int (*mr_strip_meta_fn)(void *ctx, int index, int *w, int *h); + +/* 建排版表。0=成功;参数非法/内存不足 -1(已清空、可安全 free)。 */ +int mr_strip_build(mr_strip *s, int count, int vw, int vh, + mr_strip_meta_fn meta, void *ctx); +/* 释放偏移表并清零(可重复调用)。 */ +void mr_strip_free(mr_strip *s); + +int mr_strip_page_h(const mr_strip *s, int index); /* 越界 0 */ +int mr_strip_top(const mr_strip *s, int index); /* index==count 返回总高;越界 0 */ +int mr_strip_total(const mr_strip *s); +int mr_strip_max_offset(const mr_strip *s); /* max(0, total-vh) */ +/* 视口顶端(off)落在哪一页;空册 -1。 */ +int mr_strip_page_at(const mr_strip *s, int off); +/* 落位到某页顶端所需的滚动量(夹到 [0,max_offset]);空册 -1。 */ +int mr_strip_offset_for_page(const mr_strip *s, int page); +/* 视口可见页范围 [first,last] + ±1 解码窗,条数不超过 max_pages。 + * 返回窗口页数;空册 0。first/last 可 NULL。 */ +int mr_strip_window(const mr_strip *s, int off, int max_pages, + int *first, int *last); +int mr_strip_at_end(const mr_strip *s, int off); /* 1=已到条底 */ +int mr_strip_at_start(const mr_strip *s, int off); /* 1=已在条顶 */ + +/* ---- 跳页(chrome 滑块):目标页预解码完成后再落位,避免白屏/闪烁 ---- */ +typedef struct mr_jump { + int pending; /* 1=正在等目标页 */ + int target; /* 目标页;pending==0 时无意义 */ + unsigned long long t0; /* 开始等待的时刻(ms) */ +} mr_jump; + +/* 开始跳页:page 夹到 [0,count);count<=0 或 page<0 时取消。 */ +void mr_jump_begin(mr_jump *j, int page, int count, unsigned long long now); +int mr_jump_active(const mr_jump *j); +int mr_jump_target(const mr_jump *j); /* 无 pending 返回 -1 */ +void mr_jump_cancel(mr_jump *j); +/* 轮询:have_img=目标页是否已解码。1=可以落位(已清除 pending); + * timed_out 非 NULL 时写入「是否因超时落位」(调用方据此提示降级)。 + * 未就绪且未超时返回 0(继续等)。 */ +int mr_jump_poll(mr_jump *j, int have_img, unsigned long long now, + unsigned long long timeout_ms, int *timed_out); + #ifdef __cplusplus } #endif diff --git a/tuku/test/run_webtoon_tests.sh b/tuku/test/run_webtoon_tests.sh new file mode 100755 index 0000000..4fd3e79 --- /dev/null +++ b/tuku/test/run_webtoon_tests.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# run_webtoon_tests.sh — M7 条漫/模式/续读/跳页宿主测试(纯 C,不链 SDK、不需要设备)。 +# - test_webtoon_strip:条漫排版(适宽高度/偏移表/可见窗口/未知尺寸兜底/跳页互逆) +# - test_jump_page:跳页状态机(预解码落位/超时降级/非法目标)与落位偏移映射 +# - test_mode_persist:阅读模式与每册 last page 的 meow_config 持久化/坏值/写失败 +# - test_cont_adjacent:册间自然序边界(首/末册/嵌套/符号链接)+ 书库续读角标 +# 编译口径同 test/run_tests.sh(-I. + SDK 头,不链 meow_ui.a;配置/日志用存根)。 +set -e +cd "$(dirname "$0")/.." +SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" +CFLAGS="-Wall -Wextra -O1 -I. -I$SDK" +OUT="build/host" +mkdir -p "$OUT" + +gcc $CFLAGS test/test_webtoon_strip.c logic/reader/nav.c -o "$OUT/test_webtoon_strip" +"$OUT/test_webtoon_strip" + +gcc $CFLAGS test/test_jump_page.c logic/reader/nav.c -o "$OUT/test_jump_page" +"$OUT/test_jump_page" + +gcc $CFLAGS test/test_mode_persist.c logic/reader.c test/settings_reader_stub.c \ + -o "$OUT/test_mode_persist" +"$OUT/test_mode_persist" + +gcc $CFLAGS test/test_cont_adjacent.c logic/library.c logic/reader.c \ + test/settings_reader_stub.c -o "$OUT/test_cont_adjacent" +"$OUT/test_cont_adjacent" + +# 可选:真实 SDK 源码树上的阅读器集成测试(模式切换/续读/跳页/册间衔接)。 +# 只发布 meow_ui.a 的交付环境自动 SKIP(与 test/run_tests.sh 的 settings_lock_host 同口径)。 +if [ -f "$SDK/meow_ui_view.c" ] && [ -f "$SDK/meow_ui_config.c" ]; then + gcc $CFLAGS -DMEOW_UI_HOST_TEST -DMR_READER_HOST_TEST -I"$SDK/include" \ + test/test_mode_switch_host.c ui/reader/reader.c \ + logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.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 -o "$OUT/test_mode_switch_host" + "$OUT/test_mode_switch_host" +else + echo "SKIP test_mode_switch_host: SDK 源码树不可用(只发布 meow_ui.a 的环境)" +fi diff --git a/tuku/test/test_cont_adjacent.c b/tuku/test/test_cont_adjacent.c new file mode 100644 index 0000000..298f604 --- /dev/null +++ b/tuku/test/test_cont_adjacent.c @@ -0,0 +1,249 @@ +/* test_cont_adjacent.c — 册间顺序边界 + 书库续读角标宿主测试(无 SDK/硬件)。 + * + * 覆盖: + * A1 「直接含图」判定:含图目录=书;只有子目录含图/空目录/仅非图文件=不是书; + * A2 兄弟册排序:同父目录、书库自然序(vol2 < vol10);首/末册无相邻返回 0; + * A3 边界:末页前进→下一册、首页后退→上一册(调用方语义);路径末尾斜杠等价; + * A4 排除:隐藏/系统目录、符号链接、普通文件不参与兄弟册; + * A5 参数:delta 非 ±1、NULL/小缓冲、不存在的目录; + * A6 书库扫描接线:有续读记录的册,行副标题出现「· 续读 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/run_webtoon_tests.sh。 + */ +#include "logic/library.h" +#include "logic/reader.h" +#include "test/settings_reader_stub.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; + int ok = fwrite("x", 1, 1, f) == 1; + fclose(f); + return ok ? 0 : -1; +} + +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); +} + +static void test_has_images(const char *root) +{ + char p[MR_PATH_MAX]; + CHECK(mr_library_dir_has_images(root) == 1, + "root has a loose image file (scan/adjacency only treats subdirs as books)"); + + joinp(p, sizeof p, root, "vol1"); + CHECK(mr_library_dir_has_images(p) == 1, "vol1 is a book"); + joinp(p, sizeof p, root, "dirs_only"); + CHECK(mr_library_dir_has_images(p) == 0, "dirs_only is not a book"); + joinp(p, sizeof p, root, "empty"); + CHECK(mr_library_dir_has_images(p) == 0, "empty is not a book"); + joinp(p, sizeof p, root, "txonly"); + CHECK(mr_library_dir_has_images(p) == 0, "text-only dir is not a book"); + CHECK(mr_library_dir_has_images("/no/such/dir/xyz") == 0, "missing dir"); + CHECK(mr_library_dir_has_images(NULL) == 0 && mr_library_dir_has_images("") == 0, + "NULL/empty safe"); +} + +static void expect_neighbor(const char *dir, int delta, const char *want, const char *tag) +{ + char out[MR_PATH_MAX]; + int r = mr_book_adjacent(dir, delta, out, (int)sizeof out); + if (!want) { + CHECK(r == 0 && out[0] == '\0', "%s: no neighbor (r=%d out=%s)", tag, r, out); + } else { + CHECK(r == 1 && strcmp(out, want) == 0, "%s: got %s (want %s)", tag, out, want); + } +} + +static void test_adjacent(const char *root) +{ + char vol1[MR_PATH_MAX], vol2[MR_PATH_MAX], vol10[MR_PATH_MAX]; + char series_a[MR_PATH_MAX], series_b[MR_PATH_MAX]; + char dirs_only[MR_PATH_MAX], out[MR_PATH_MAX]; + + joinp(vol1, sizeof vol1, root, "vol1"); + joinp(vol2, sizeof vol2, root, "vol2"); + joinp(vol10, sizeof vol10, root, "vol10"); + joinp(series_a, sizeof series_a, root, "series/a"); + joinp(series_b, sizeof series_b, root, "series/b"); + joinp(dirs_only, sizeof dirs_only, root, "dirs_only"); + + /* 自然序:vol1 < vol2 < vol10(vol0 是符号链接,不参与) */ + expect_neighbor(vol1, 1, vol2, "vol1 next"); + expect_neighbor(vol2, 1, vol10, "vol2 next"); + expect_neighbor(vol10, 1, NULL, "vol10 next (end)"); + expect_neighbor(vol1, -1, NULL, "vol1 prev (start)"); + expect_neighbor(vol2, -1, vol1, "vol2 prev"); + expect_neighbor(vol10, -1, vol2, "vol10 prev"); + + /* 末尾斜杠等价 */ + char slash[MR_PATH_MAX]; + snprintf(slash, sizeof slash, "%s/", vol2); + expect_neighbor(slash, 1, vol10, "trailing slash next"); + expect_neighbor(slash, -1, vol1, "trailing slash prev"); + + /* 嵌套父目录:series/a <-> series/b */ + expect_neighbor(series_a, 1, series_b, "series a next"); + expect_neighbor(series_b, -1, series_a, "series b prev"); + expect_neighbor(series_b, 1, NULL, "series b next (end)"); + + /* 不是书的目录:不在兄弟表中 */ + expect_neighbor(dirs_only, 1, NULL, "dirs_only not a book"); + expect_neighbor(dirs_only, -1, NULL, "dirs_only not a book prev"); + + /* 路径参数:delta/缓冲/不存在 */ + CHECK(mr_book_adjacent(vol1, 0, out, (int)sizeof out) == -1, "delta 0 invalid"); + CHECK(mr_book_adjacent(vol1, 2, out, (int)sizeof out) == -1, "delta 2 invalid"); + CHECK(mr_book_adjacent(vol1, -2, out, (int)sizeof out) == -1, "delta -2 invalid"); + CHECK(mr_book_adjacent(NULL, 1, out, (int)sizeof out) == -1, "NULL dir"); + CHECK(mr_book_adjacent(vol1, 1, NULL, 32) == -1, "NULL out"); + CHECK(mr_book_adjacent(vol1, 1, out, 4) == 0, "small out -> no fit (0)"); + CHECK(mr_book_adjacent("/no/such/book", 1, out, (int)sizeof out) == 0, + "missing parent -> 0"); + CHECK(mr_book_adjacent("/", 1, out, (int)sizeof out) == -1, "root path invalid"); +} + +static void test_library_badge(const char *root) +{ + mr_test_cfg_clear(); + char vol1[MR_PATH_MAX], vol2[MR_PATH_MAX]; + joinp(vol1, sizeof vol1, root, "vol1"); + joinp(vol2, sizeof vol2, root, "vol2"); + + /* vol1 有续读记录(第 5 页),vol2 没有 */ + mr_cont_save(vol1, 4, MR_MODE_WEBTOON); + + mr_library lib; + memset(&lib, 0, sizeof lib); + 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); + } + 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(seen1 == 1, "vol1 listed"); + CHECK(seen2 == 1, "vol2 listed"); + 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"); + 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"); + mr_library_free(&lib); +} + +int main(void) +{ + char tmpl[] = "./mr_cont_adj_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return 1; + + char p[MR_PATH_MAX]; + const char *books[] = { "vol1", "vol2", "vol10", "series/a", "series/b", "dirs_only/inside" }; + for (unsigned i = 0; i < sizeof books / sizeof books[0]; i++) { + joinp(p, sizeof p, root, books[i]); + CHECK(mkdir_p(p) == 0, "mkdir %s", p); + char img[MR_PATH_MAX]; + joinp(img, sizeof img, p, "001.png"); + CHECK(touch_file(img) == 0, "image %s", img); + } + joinp(p, sizeof p, root, "empty"); + mkdir_p(p); + joinp(p, sizeof p, root, "txonly"); + mkdir_p(p); + joinp(p, sizeof p, root, "txonly/readme.txt"); + touch_file(p); + joinp(p, sizeof p, root, ".hidden"); + mkdir_p(p); + joinp(p, sizeof p, root, ".hidden/001.png"); + touch_file(p); + joinp(p, sizeof p, root, "MeowOS"); + mkdir_p(p); + joinp(p, sizeof p, root, "MeowOS/001.png"); + touch_file(p); + joinp(p, sizeof p, root, "loose.png"); + touch_file(p); + + char vol1[MR_PATH_MAX], vol0[MR_PATH_MAX]; + joinp(vol1, sizeof vol1, root, "vol1"); + joinp(vol0, sizeof vol0, root, "vol0"); + if (symlink(vol1, vol0) == 0) { /* 有权限才测;无权限时 symlink 失败也不影响上面的断言 */ } + + test_has_images(root); + test_adjacent(root); + test_library_badge(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_jump_page.c b/tuku/test/test_jump_page.c new file mode 100644 index 0000000..7441a21 --- /dev/null +++ b/tuku/test/test_jump_page.c @@ -0,0 +1,140 @@ +/* test_jump_page.c — 跳页状态机 + 条漫落位映射宿主测试(无 SDK/硬件)。 + * + * 覆盖: + * J1 跳页状态:begin 夹取/非法取消、active/target、预解码就绪落位、超时降级落位; + * J2 落位映射:mr_strip_offset_for_page 与 page_at 互逆(首/中/末页,max 夹取); + * J3 边界:空册/越界/重复取消;正常等待不提前落位(不白屏——没有图就不落)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. test/test_jump_page.c logic/reader/nav.c \ + * -o test/test_jump_page && ./test/test_jump_page + * 或 test/run_webtoon_tests.sh。 + */ +#include "logic/reader/nav.h" + +#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) + +#define VW 480 +#define VH 720 +#define TIMEOUT 2500ULL + +static int g_meta_w[8], g_meta_h[8], g_meta_n; + +static int meta_cb(void *ctx, int i, int *w, int *h) +{ + (void)ctx; + if (i < 0 || i >= g_meta_n) return 0; + *w = g_meta_w[i]; + *h = g_meta_h[i]; + return 1; +} + +static void build_strip(mr_strip *s, int pages) +{ + memset(s, 0, sizeof *s); + g_meta_n = pages; + for (int i = 0; i < pages; i++) { + g_meta_w[i] = 800; + g_meta_h[i] = 1200; /* 适宽后每页 720px */ + } + CHECK(mr_strip_build(s, pages, VW, VH, meta_cb, NULL) == 0, "build strip pages=%d", pages); +} + +static void test_jump_state(void) +{ + mr_jump j; + memset(&j, 0, sizeof j); + CHECK(mr_jump_active(&j) == 0, "fresh inactive"); + CHECK(mr_jump_target(&j) == -1, "fresh target -1"); + + /* 正常:开始 → 未就绪继续等 → 就绪落位 */ + mr_jump_begin(&j, 4, 10, 100); + CHECK(mr_jump_active(&j) == 1 && mr_jump_target(&j) == 4, "begin active target=4"); + int to = -1; + CHECK(mr_jump_poll(&j, 0, 200, TIMEOUT, &to) == 0 && to == 0, "waiting no timeout"); + CHECK(mr_jump_active(&j) == 1, "still active while waiting"); + CHECK(mr_jump_poll(&j, 1, 300, TIMEOUT, &to) == 1 && to == 0, "ready lands"); + CHECK(mr_jump_active(&j) == 0 && mr_jump_target(&j) == -1, "cleared after land"); + + /* 超时:仍然落位,但标记降级 */ + mr_jump_begin(&j, 2, 10, 1000); + CHECK(mr_jump_poll(&j, 0, 1000 + TIMEOUT - 1, TIMEOUT, &to) == 0, "just before timeout waits"); + CHECK(mr_jump_poll(&j, 0, 1000 + TIMEOUT, TIMEOUT, &to) == 1 && to == 1, "timeout lands"); + CHECK(mr_jump_active(&j) == 0, "timeout clears"); + + /* 就绪与超时同时成立:优先算正常就绪 */ + mr_jump_begin(&j, 1, 10, 0); + CHECK(mr_jump_poll(&j, 1, 999999, TIMEOUT, &to) == 1 && to == 0, + "ready wins over timeout"); + + /* timeout=0 表示不限时 */ + mr_jump_begin(&j, 1, 10, 0); + CHECK(mr_jump_poll(&j, 0, 999999999ULL, 0, &to) == 0 && to == 0, "no timeout when 0"); + + /* begin 夹取/非法 */ + mr_jump_begin(&j, -1, 10, 0); + CHECK(mr_jump_active(&j) == 0, "begin(-1) cancels"); + mr_jump_begin(&j, 10, 10, 0); + CHECK(mr_jump_active(&j) == 0, "begin(count) cancels"); + mr_jump_begin(&j, 0, 0, 0); + CHECK(mr_jump_active(&j) == 0, "begin empty book cancels"); + + /* 取消幂等;NULL 安全 */ + mr_jump_begin(&j, 3, 10, 5); + mr_jump_cancel(&j); + mr_jump_cancel(&j); + CHECK(mr_jump_active(&j) == 0, "cancel idempotent"); + CHECK(mr_jump_poll(NULL, 1, 1, TIMEOUT, &to) == 0, "poll NULL"); + mr_jump_cancel(NULL); + mr_jump_begin(NULL, 0, 10, 0); + + /* timed_out 可为 NULL */ + mr_jump_begin(&j, 5, 10, 0); + CHECK(mr_jump_poll(&j, 0, TIMEOUT + 1, TIMEOUT, NULL) == 1, "poll NULL timed_out"); +} + +static void test_land_mapping(void) +{ + mr_strip s; + build_strip(&s, 5); + CHECK(mr_strip_total(&s) == 3600, "total=%d", mr_strip_total(&s)); + CHECK(mr_strip_max_offset(&s) == 2880, "max=%d", mr_strip_max_offset(&s)); + + int expect_top[5] = { 0, 720, 1440, 2160, 2880 }; + for (int i = 0; i < 5; i++) { + int off = mr_strip_offset_for_page(&s, i); + CHECK(off == expect_top[i], "page %d offset %d (want %d)", i, off, expect_top[i]); + CHECK(mr_strip_page_at(&s, off) == i, "page_at(offset(%d)) == %d", i, i); + } + /* 末页顶端恰在 max(整页铺满视口),落位不会越界 */ + CHECK(mr_strip_offset_for_page(&s, 4) == mr_strip_max_offset(&s), "last page == max"); + /* page+1 的偏移等于 page 的末端(页间无缝) */ + CHECK(mr_strip_top(&s, 1) == mr_strip_top(&s, 0) + mr_strip_page_h(&s, 0), + "page tops contiguous"); + + /* 跳页目标页必须在解码窗里(否则会被淘汰):窗口只给 1 槽时也能覆盖目标 */ + int f = -1, l = -1; + CHECK(mr_strip_window(&s, 0, 1, &f, &l) == 1 && f == l, "single-slot window"); + mr_strip_free(&s); + + /* 空册:没有可落位目标 */ + mr_strip e; + memset(&e, 0, sizeof e); + CHECK(mr_strip_offset_for_page(&e, 0) == -1, "empty book no offset"); + CHECK(mr_strip_page_at(&e, 0) == -1, "empty book no page"); +} + +int main(void) +{ + test_jump_state(); + test_land_mapping(); + 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_mode_persist.c b/tuku/test/test_mode_persist.c new file mode 100644 index 0000000..238f48e --- /dev/null +++ b/tuku/test/test_mode_persist.c @@ -0,0 +1,165 @@ +/* test_mode_persist.c — 阅读模式 + 每册续读状态持久化宿主测试(无 SDK/硬件)。 + * + * 覆盖: + * P1 模式值:valid/name/parse(paged/webtoon,非法值回退); + * P2 册标识键:reader.page/mode.,末尾斜杠等价、不同册不同键、 + * 键字符集安全([A-Za-z0-9._-]); + * P3 默认值:无记录时 get_mode/get_page 返回调用方给的默认值; + * P4 读写往返 + 册间隔离 + 一次 save 写两键; + * P5 坏值/非法模式忽略(不覆盖已有记录);写失败只 WARN 不崩; + * P6 书库角标:page>0 → "续读 Pn";page==0/无记录 → 无角标。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. test/test_mode_persist.c logic/reader.c \ + * test/settings_reader_stub.c -o test/test_mode_persist + * 或 test/run_webtoon_tests.sh。 + */ +#include "logic/reader.h" +#include "test/settings_reader_stub.h" +#include "include/meow_ui_config.h" + +#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 void test_mode_values(void) +{ + CHECK(mr_mode_valid(MR_MODE_PAGED) == 1, "paged valid"); + CHECK(mr_mode_valid(MR_MODE_WEBTOON) == 1, "webtoon valid"); + CHECK(mr_mode_valid(-1) == 0 && mr_mode_valid(2) == 0, "invalid modes"); + CHECK(strcmp(mr_mode_name(MR_MODE_PAGED), "paged") == 0, "name paged"); + CHECK(strcmp(mr_mode_name(MR_MODE_WEBTOON), "webtoon") == 0, "name webtoon"); + CHECK(strcmp(mr_mode_name(99), "paged") == 0, "name invalid -> paged"); + CHECK(mr_mode_parse("paged") == MR_MODE_PAGED, "parse paged"); + CHECK(mr_mode_parse("webtoon") == MR_MODE_WEBTOON, "parse webtoon"); + CHECK(mr_mode_parse("WEBTOON") == -1, "parse case sensitive"); + CHECK(mr_mode_parse("") == -1 && mr_mode_parse(NULL) == -1, "parse empty/NULL"); +} + +static void test_keys(void) +{ + char a[64], b[64], c[64]; + CHECK(mr_cont_key("/sd/book1", "page", a, (int)sizeof a) == 0, "key ok"); + CHECK(strncmp(a, "reader.page.", 12) == 0, "key prefix %s", a); + CHECK(strlen(a) == 12 + 16, "key len=%zu", strlen(a)); + for (size_t i = 12; i < strlen(a); i++) + CHECK(isxdigit((unsigned char)a[i]) != 0, "key hex char"); + CHECK(mr_cont_key("/sd/book1/", "page", b, (int)sizeof b) == 0, "key trailing slash"); + CHECK(strcmp(a, b) == 0, "trailing slash normalizes: %s vs %s", a, b); + CHECK(mr_cont_key("/sd/book1", "mode", c, (int)sizeof c) == 0, "mode key"); + CHECK(strncmp(c, "reader.mode.", 12) == 0, "mode prefix %s", c); + CHECK(strcmp(a, c) != 0, "field differs"); + CHECK(mr_cont_key("/sd/book2", "page", c, (int)sizeof c) == 0, "book2 key"); + CHECK(strcmp(a, c) != 0, "different books differ"); + + /* 参数/缓冲边界 */ + CHECK(mr_cont_key(NULL, "page", a, (int)sizeof a) == -1, "NULL dir"); + CHECK(mr_cont_key("", "page", a, (int)sizeof a) == -1, "empty dir"); + CHECK(mr_cont_key("/a", NULL, a, (int)sizeof a) == -1, "NULL field"); + CHECK(mr_cont_key("/a", "page", NULL, 32) == -1, "NULL out"); + CHECK(mr_cont_key("/a", "page", a, 8) == -1 && a[0] == '\0', "small out"); +} + +static void test_defaults_and_roundtrip(void) +{ + mr_test_cfg_clear(); + CHECK(mr_cont_get_mode("/sd/a", MR_MODE_PAGED) == MR_MODE_PAGED, "default paged"); + CHECK(mr_cont_get_mode("/sd/a", MR_MODE_WEBTOON) == MR_MODE_WEBTOON, "default param used"); + CHECK(mr_cont_get_page("/sd/a", 0) == 0, "default page 0"); + CHECK(mr_cont_get_page("/sd/a", 7) == 7, "default page param"); + CHECK(mr_cont_badge("/sd/a", NULL, 0) == 0, "badge NULL safe"); + + char badge[24]; + CHECK(mr_cont_badge("/sd/a", badge, (int)sizeof badge) == 0 && badge[0] == '\0', + "no record -> no badge"); + + mr_cont_set_mode("/sd/a", MR_MODE_WEBTOON); + CHECK(mr_cont_get_mode("/sd/a", MR_MODE_PAGED) == MR_MODE_WEBTOON, "mode roundtrip"); + CHECK(mr_cont_get_mode("/sd/b", MR_MODE_PAGED) == MR_MODE_PAGED, "mode isolated per book"); + + mr_cont_set_page("/sd/a", 17); + CHECK(mr_cont_get_page("/sd/a", -1) == 17, "page roundtrip"); + CHECK(mr_cont_get_page("/sd/b", -1) == -1, "page isolated per book"); + CHECK(mr_cont_badge("/sd/a", badge, (int)sizeof badge) == 1, "badge shown"); + CHECK(strcmp(badge, "续读 P18") == 0, "badge text %s", badge); + + /* 一次 save 两键(模式 + 页码) */ + mr_cont_save("/sd/c", 3, MR_MODE_WEBTOON); + CHECK(mr_cont_get_page("/sd/c", -1) == 3, "save page"); + CHECK(mr_cont_get_mode("/sd/c", MR_MODE_PAGED) == MR_MODE_WEBTOON, "save mode"); + CHECK(mr_cont_badge("/sd/c", badge, (int)sizeof badge) == 1 && + strcmp(badge, "续读 P4") == 0, "save badge %s", badge); + + /* page 0 视为「没有进度」,不出角标 */ + mr_cont_save("/sd/d", 0, MR_MODE_PAGED); + CHECK(mr_cont_badge("/sd/d", badge, (int)sizeof badge) == 0 && badge[0] == '\0', + "page 0 -> no badge"); + + /* NULL/空路径安全 */ + mr_cont_set_page(NULL, 5); + mr_cont_set_mode(NULL, MR_MODE_WEBTOON); + mr_cont_save(NULL, 1, 1); + mr_cont_save("", 1, 1); + CHECK(mr_cont_get_page(NULL, 9) == 9 && mr_cont_get_mode(NULL, MR_MODE_PAGED) == MR_MODE_PAGED, + "NULL dir safe"); +} + +static void test_bad_values(void) +{ + mr_test_cfg_clear(); + char key[64]; + char buf[24]; + + /* 坏模式值 → 默认;坏页码(负数/非数字/超上限)→ 默认 */ + CHECK(mr_cont_key("/sd/x", "mode", key, (int)sizeof key) == 0, "bad key"); + mr_test_cfg_put(key, "vertical"); + CHECK(mr_cont_get_mode("/sd/x", MR_MODE_PAGED) == MR_MODE_PAGED, "bad mode -> def"); + + CHECK(mr_cont_key("/sd/x", "page", key, (int)sizeof key) == 0, "bad page key"); + mr_test_cfg_put(key, "-3"); + CHECK(mr_cont_get_page("/sd/x", 11) == 11, "negative page -> def"); + mr_test_cfg_put(key, "abc"); + CHECK(mr_cont_get_page("/sd/x", 12) == 12, "non-numeric page -> def"); + mr_test_cfg_put(key, "99999999"); + CHECK(mr_cont_get_page("/sd/x", 13) == 13, "too-large page -> def"); + mr_test_cfg_put(key, "0"); + CHECK(mr_cont_get_page("/sd/x", 14) == 0, "zero page readable"); + + /* 非法模式值不覆盖已有记录 */ + mr_cont_set_mode("/sd/y", MR_MODE_WEBTOON); + mr_cont_set_mode("/sd/y", 42); + CHECK(mr_cont_get_mode("/sd/y", MR_MODE_PAGED) == MR_MODE_WEBTOON, "invalid set ignored"); + + /* 写失败:只告警,内存保持旧值;不崩 */ + mr_cont_save("/sd/z", 5, MR_MODE_PAGED); + int warns = mr_test_log_warns(); + mr_test_cfg_fail_writes(1); + mr_cont_set_page("/sd/z", 9); + mr_cont_set_mode("/sd/z", MR_MODE_WEBTOON); + mr_cont_save("/sd/z", 10, MR_MODE_WEBTOON); + CHECK(mr_test_log_warns() > warns, "write failure warns (warns=%d)", mr_test_log_warns()); + CHECK(mr_cont_get_page("/sd/z", -1) == 5, "failed write keeps old page"); + CHECK(mr_cont_get_mode("/sd/z", MR_MODE_PAGED) == MR_MODE_PAGED, "failed write keeps old mode"); + mr_test_cfg_fail_writes(0); + + /* 写入后读回同一字符串(便于排查:paged/webtoon 明文) */ + CHECK(mr_cont_key("/sd/y", "mode", key, (int)sizeof key) == 0, "read mode key"); + CHECK(mr_test_cfg_get(key, buf, (int)sizeof buf) == 1 && strcmp(buf, "webtoon") == 0, + "stored mode string %s", buf); +} + +int main(void) +{ + test_mode_values(); + test_keys(); + test_defaults_and_roundtrip(); + test_bad_values(); + 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_mode_switch_host.c b/tuku/test/test_mode_switch_host.c new file mode 100644 index 0000000..1a7d9d8 --- /dev/null +++ b/tuku/test/test_mode_switch_host.c @@ -0,0 +1,273 @@ +/* test_mode_switch_host.c — 阅读器模式/续读/跳页/册间衔接的宿主集成测试(可选,需 SDK 源码树)。 + * + * 只做纯逻辑测试覆盖不到的接缝:真实 SDK view/activity/widget + 真实图片解码 + 真实 + * meow_scroll/meow_anim 节拍。用 40x60 BMP 当页图(stb 可解,无外部依赖),在临时目录 + * 建两本相邻册,然后模拟用户操作: + * H1 进入:默认翻页模式、页码 0、控件归属 ACT_READER(顶栏在 on_enter 建); + * H2 chrome 图标一键切模式:翻页 ⇄ 条漫,切换立即写 meow_config(每册记忆); + * H3 跳页滑块:展开 → 拖到末页 → 目标页解码完成后落位(不是先白屏再跳); + * H4 离开/重进:恢复上次页 + 模式(续读); + * H5 册间衔接:条漫末页上拉 → 下一册首页;首页下拉 → 上一册末页;首册再无上一册时 + * 停在原地(不退出、不崩)。 + * + * 编译:test/run_webtoon_tests.sh(需 SDK 源码树;只发布 meow_ui.a 时自动 SKIP)。 + */ +#include "tuku_internal.h" +#include "ui/reader/reader.h" +#include "include/meow_ui_anim.h" +#include "meow_ui_internal.h" /* MEOW_EV_TAP / MEOW_EV_SWIPE(宿主测试内部头) */ + +#include +#include +#include +#include +#include +#include + +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) {} + +static int g_lib_enters; +static void lib_enter(meow_view *v, void *c) { (void)v; (void)c; g_lib_enters++; } +static void lib_leave(meow_view *v, void *c) { (void)v; (void)c; } + +/* ---- 临时目录 + 最小 BMP24(stb 可解) ---- */ +static void put16(unsigned char *p, unsigned v) { p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); } +static void put32(unsigned char *p, unsigned v) +{ + p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); + p[2] = (unsigned char)(v >> 16); p[3] = (unsigned char)(v >> 24); +} + +static int write_bmp24(const char *path, int w, int h) +{ + int stride = ((w * 3 + 3) / 4) * 4; + int imgsz = stride * h; + unsigned char fh[14] = { 0 }, ih[40] = { 0 }; + fh[0] = 'B'; fh[1] = 'M'; + put32(fh + 2, (unsigned)(54 + imgsz)); + put32(fh + 10, 54); + put32(ih + 0, 40); + put32(ih + 4, (unsigned)w); + put32(ih + 8, (unsigned)h); + put16(ih + 12, 1); + put16(ih + 14, 24); + put32(ih + 20, (unsigned)imgsz); + + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh && + fwrite(ih, 1, sizeof ih, f) == sizeof ih; + unsigned char *row = (unsigned char *)calloc(1, (size_t)stride); + if (!row) ok = 0; + for (int y = 0; ok && y < h; y++) + if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0; + free(row); + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +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 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); +} + +/* 驱动 SDK 动画节拍(宿主没有 meow_ui_run 主循环)。 */ +static void pump_ms(int ms) +{ + for (int i = 0; i < ms / 10; i++) { + usleep(10 * 1000); + meow_anim_tick(); + } +} + +/* 模拟 app.c 的点击路由:on_touch_down → on_tap(阅读页 tap 不在 view on_event 里)。 */ +static int tap(int x, int y) +{ + reader_on_touch_down(x, y); + reader_on_tap(x, y); + return 1; +} + +static int swipe(int dir, int dist) +{ + return meow_view_dispatch_event(MEOW_EV_SWIPE, dir, dist); +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "."; + char tmpl[512]; + snprintf(tmpl, sizeof tmpl, "%s/mr_mode_host_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], book1[MR_PATH_MAX], book2[MR_PATH_MAX]; + snprintf(root, sizeof root, "./mr_mode_books_%ld", (long)getpid()); + snprintf(book1, sizeof book1, "%s/vol1", root); + snprintf(book2, sizeof book2, "%s/vol2", root); + mkdir_p(book1); + mkdir_p(book2); + for (int b = 0; b < 2; b++) { + const char *dir = b ? book2 : book1; + for (int i = 0; i < 3; i++) { + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/p%d.bmp", dir, i + 1); + if (write_bmp24(p, 40, 60) != 0) { printf("SKIP: bmp write failed\n"); return 0; } + } + } + + meow_ui_opts o = { 480, 720 }; + if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; } + + static const meow_view_hooks lh = { .on_enter = lib_enter, .on_leave = lib_leave }; + meow_view *lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL); + meow_view *reader = reader_view_create(); + meow_view_page_push(ACT_LIBRARY); + + /* TopAppBar 动作格(位图):右起每格 48px;动作 0=模式 / 1=跳页 / 2=fit。 */ + int w = meow_ui_width(); + int mode_x = w - 10 - 48 * 3 + 24; + int jump_x = w - 10 - 48 * 2 + 24; + int bar_y = 28; + int slider_y = 56 + 4 + 20; + int sx = 24, sw = w - 48; + + /* ---- H1 进入:翻页模式、第 1 页 ---- */ + mr_reader_open_dir(book1); + CHECK(meow_ui_activity_active() == ACT_READER, "reader active after open"); + CHECK(mr_reader_ui_count() == 3, "book pages=%d", mr_reader_ui_count()); + CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "default mode is paged"); + CHECK(mr_reader_ui_page() == 0, "start at page 0"); + pump_ms(300); + + /* ---- H2 模式切换:chrome 图标一键切,立即持久化 ---- */ + CHECK(tap(mode_x, bar_y) == 1, "mode icon tap consumed"); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "switched to webtoon"); + CHECK(mr_cont_get_mode(book1, MR_MODE_PAGED) == MR_MODE_WEBTOON, + "webtoon mode persisted per book"); + CHECK(tap(mode_x, bar_y) == 1, "mode icon tap consumed (back)"); + CHECK(mr_reader_ui_mode() == MR_MODE_PAGED, "switched back to paged"); + CHECK(mr_cont_get_mode(book1, MR_MODE_PAGED) == MR_MODE_PAGED, "paged mode persisted"); + CHECK(tap(mode_x, bar_y) == 1, "mode icon tap consumed (again)"); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon again for continuity"); + + /* ---- H3 跳页:滑块展开 → 拖到末页 → 预解码完成后落位 ---- */ + CHECK(tap(jump_x, bar_y) == 1, "jump icon tap consumed"); + CHECK(mr_reader_ui_jump_open() == 1, "slider open"); + reader_on_touch_down(sx + sw, slider_y); + reader_on_tap(sx + sw, slider_y); + CHECK(mr_reader_ui_jump_open() == 0, "slider closed after pick"); + CHECK(mr_reader_ui_jump_pending() == 1 || mr_reader_ui_page() == 2, + "jump pending (waiting for target decode)"); + pump_ms(600); + CHECK(mr_reader_ui_jump_pending() == 0, "jump landed"); + CHECK(mr_reader_ui_page() == 2, "jumped to last page (got %d)", mr_reader_ui_page()); + + /* ---- H4 续读:离开保存 page+mode,重进恢复 ---- */ + CHECK(swipe(MEOW_SWIPE_LEFT, 200) == 0, "reader leaves back-swipe to app.c"); + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "back to library"); + CHECK(mr_cont_get_page(book1, -1) == 2, "last page saved (got %d)", + mr_cont_get_page(book1, -1)); + CHECK(mr_cont_get_mode(book1, MR_MODE_PAGED) == MR_MODE_WEBTOON, "mode saved"); + CHECK(g_lib_enters >= 2, "library re-entered"); + + mr_reader_open_dir(book1); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "mode restored on reopen"); + CHECK(mr_reader_ui_page() == 2, "page restored on reopen (got %d)", mr_reader_ui_page()); + pump_ms(300); + + /* ---- H5 册间衔接:条漫末页上拉 → 下一册首页 ---- */ + reader_on_touch_down(240, 600); + for (int i = 1; i <= 6; i++) { + reader_on_touch_move(240, 600 - i * 40); + usleep(5 * 1000); + } + CHECK(swipe(MEOW_SWIPE_UP, 240) == 1, "webtoon up-swipe consumed"); + CHECK(strcmp(mr_reader_dir(), book2) == 0, "advanced to next book (got %s)", mr_reader_dir()); + CHECK(mr_reader_ui_page() == 0, "next book starts at page 0"); + CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "mode carried into fresh book"); + pump_ms(300); + + /* 首页下拉 → 上一册末页 */ + reader_on_touch_down(240, 300); + for (int i = 1; i <= 6; i++) { + reader_on_touch_move(240, 300 + i * 40); + usleep(5 * 1000); + } + CHECK(swipe(MEOW_SWIPE_DOWN, 240) == 1, "webtoon down-swipe consumed"); + CHECK(strcmp(mr_reader_dir(), book1) == 0, "back to previous book (got %s)", mr_reader_dir()); + CHECK(mr_reader_ui_page() == 2, "previous book lands at last page (got %d)", + mr_reader_ui_page()); + pump_ms(300); + + /* 跳到首页后,再往上一册:没有相邻册,停在原地并提示(不退出/不崩)。 */ + CHECK(tap(jump_x, bar_y) == 1, "jump icon tap consumed (to first page)"); + CHECK(mr_reader_ui_jump_open() == 1, "slider open again"); + reader_on_touch_down(sx + 2, slider_y); + reader_on_tap(sx + 2, slider_y); + pump_ms(500); + CHECK(mr_reader_ui_page() == 0, "jumped to first page (got %d)", mr_reader_ui_page()); + + reader_on_touch_down(240, 300); + for (int i = 1; i <= 6; i++) { + reader_on_touch_move(240, 300 + i * 40); + usleep(5 * 1000); + } + CHECK(swipe(MEOW_SWIPE_DOWN, 240) == 1, "edge down-swipe consumed"); + CHECK(strcmp(mr_reader_dir(), book1) == 0, "no previous book: stay put"); + CHECK(meow_ui_activity_active() == ACT_READER, "no previous book: still in reader (no exit)"); + + /* ---- 收尾 ---- */ + meow_view_page_pop(); + CHECK(meow_ui_activity_active() == ACT_LIBRARY, "popped back to library"); + meow_view_free(reader); + meow_view_free(lib); + meow_ui_shutdown(); + + rm_rf(root); + rm_rf(data); + printf("%s: %s\n", g_fail ? "FAIL" : "PASS", + g_fail ? "see failures above" : "reader mode/continuity integration checks passed"); + return g_fail ? 1 : 0; +} diff --git a/tuku/test/test_webtoon_strip.c b/tuku/test/test_webtoon_strip.c new file mode 100644 index 0000000..9efb483 --- /dev/null +++ b/tuku/test/test_webtoon_strip.c @@ -0,0 +1,220 @@ +/* test_webtoon_strip.c — 条漫排版/滚动窗口纯逻辑宿主测试(无 SDK/硬件)。 + * + * 覆盖: + * W1 单页:高度=适宽、总高、max_offset=0、窗口只有本页、顶/底同时成立; + * W2 多页:按页高累加 offsets;page_at 二分定位(页边界/末页/越界); + * W3 可见窗口:视口±1 页;max_pages 截断;条底/条顶窗口不越界; + * W4 尺寸兜底:未知 meta → 2:3 占位;横图适宽;极端长图夹到上限; + * W5 参数/生命周期:空册、非法视口、重复 free、重建; + * W6 跳页落位:offset_for_page 与 page_at 互逆(含末页 max 夹取)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. test/test_webtoon_strip.c logic/reader/nav.c \ + * -o test/test_webtoon_strip && ./test/test_webtoon_strip + * 或 test/run_webtoon_tests.sh。 + */ +#include "logic/reader/nav.h" + +#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) + +#define VW 480 +#define VH 720 + +typedef struct fake_meta { + int n; + int w[16]; + int h[16]; + int known[16]; +} fake_meta; + +static int meta_cb(void *ctx, int i, int *w, int *h) +{ + fake_meta *m = (fake_meta *)ctx; + if (!m || i < 0 || i >= m->n) return 0; + *w = m->w[i]; + *h = m->h[i]; + return m->known[i]; +} + +static void meta_page(fake_meta *m, int i, int w, int h, int known) +{ + m->w[i] = w; + m->h[i] = h; + m->known[i] = known; + if (i + 1 > m->n) m->n = i + 1; +} + +static void test_single_page(void) +{ + fake_meta m; + memset(&m, 0, sizeof m); + meta_page(&m, 0, 800, 1200, 1); + + mr_strip s; + memset(&s, 0, sizeof s); + CHECK(mr_strip_build(&s, 1, VW, VH, meta_cb, &m) == 0, "build single"); + CHECK(s.count == 1, "count=%d", s.count); + CHECK(mr_strip_page_h(&s, 0) == 720, "single h=%d", mr_strip_page_h(&s, 0)); + CHECK(mr_strip_total(&s) == 720, "single total=%d", mr_strip_total(&s)); + CHECK(mr_strip_max_offset(&s) == 0, "single max=%d", mr_strip_max_offset(&s)); + CHECK(mr_strip_page_at(&s, 0) == 0, "single page at 0"); + CHECK(mr_strip_page_at(&s, 719) == 0, "single page at 719"); + CHECK(mr_strip_page_at(&s, 720) == 0, "single page at total clamps"); + CHECK(mr_strip_at_end(&s, 0) == 1, "single at end"); + CHECK(mr_strip_at_start(&s, 0) == 1, "single at start"); + + int f = -1, l = -1; + CHECK(mr_strip_window(&s, 0, 4, &f, &l) == 1 && f == 0 && l == 0, + "single window %d..%d", f, l); + CHECK(mr_strip_offset_for_page(&s, 0) == 0, "single offset page0"); + CHECK(mr_strip_offset_for_page(&s, 9) == 0, "single offset OOB clamps"); + mr_strip_free(&s); +} + +static void test_multi_pages(void) +{ + fake_meta m; + memset(&m, 0, sizeof m); + /* 0: 800x1200 -> 720;1: 未知 -> 720 占位;2: 横图 1500x1000 -> 320; + * 3: 方图 400x400 -> 480;4: 极端长图 1x2000 -> 夹到 200000。 */ + meta_page(&m, 0, 800, 1200, 1); + meta_page(&m, 1, 0, 0, 0); + meta_page(&m, 2, 1500, 1000, 1); + meta_page(&m, 3, 400, 400, 1); + meta_page(&m, 4, 1, 2000, 1); + + mr_strip s; + memset(&s, 0, sizeof s); + CHECK(mr_strip_build(&s, 5, VW, VH, meta_cb, &m) == 0, "build multi"); + CHECK(mr_strip_page_h(&s, 0) == 720, "h0=%d", mr_strip_page_h(&s, 0)); + CHECK(mr_strip_page_h(&s, 1) == 720, "h1(unknown)=%d", mr_strip_page_h(&s, 1)); + CHECK(mr_strip_page_h(&s, 2) == 320, "h2(landscape)=%d", mr_strip_page_h(&s, 2)); + CHECK(mr_strip_page_h(&s, 3) == 480, "h3(square)=%d", mr_strip_page_h(&s, 3)); + CHECK(mr_strip_page_h(&s, 4) == MR_STRIP_MAX_PAGE_H, "h4(extreme)=%d", + mr_strip_page_h(&s, 4)); + + CHECK(mr_strip_top(&s, 0) == 0, "top0=%d", mr_strip_top(&s, 0)); + CHECK(mr_strip_top(&s, 1) == 720, "top1=%d", mr_strip_top(&s, 1)); + CHECK(mr_strip_top(&s, 2) == 1440, "top2=%d", mr_strip_top(&s, 2)); + CHECK(mr_strip_top(&s, 3) == 1760, "top3=%d", mr_strip_top(&s, 3)); + CHECK(mr_strip_top(&s, 4) == 2240, "top4=%d", mr_strip_top(&s, 4)); + CHECK(mr_strip_top(&s, 5) == 202240, "top[count]=%d", mr_strip_top(&s, 5)); + CHECK(mr_strip_total(&s) == 202240, "total=%d", mr_strip_total(&s)); + CHECK(mr_strip_max_offset(&s) == 201520, "max=%d", mr_strip_max_offset(&s)); + + CHECK(mr_strip_page_at(&s, 0) == 0, "page_at 0"); + CHECK(mr_strip_page_at(&s, 719) == 0, "page_at 719"); + CHECK(mr_strip_page_at(&s, 720) == 1, "page_at 720"); + CHECK(mr_strip_page_at(&s, 1439) == 1, "page_at 1439"); + CHECK(mr_strip_page_at(&s, 1440) == 2, "page_at 1440"); + CHECK(mr_strip_page_at(&s, 1759) == 2, "page_at 1759"); + CHECK(mr_strip_page_at(&s, 1760) == 3, "page_at 1760"); + CHECK(mr_strip_page_at(&s, 2239) == 3, "page_at 2239"); + CHECK(mr_strip_page_at(&s, 2240) == 4, "page_at 2240"); + CHECK(mr_strip_page_at(&s, 9999999) == 4, "page_at huge -> last"); + CHECK(mr_strip_page_at(&s, -50) == 0, "page_at negative -> first"); + CHECK(mr_strip_page_at(&s, 0) == 0 && mr_strip_page_at(NULL, 0) == -1, "page_at NULL"); + + /* 视口窗口:off=0 只看得到页 0(页 1 从 720 开始)→ ±1 = [0,1] */ + int f = -1, l = -1; + CHECK(mr_strip_window(&s, 0, 4, &f, &l) == 2 && f == 0 && l == 1, + "window top %d..%d", f, l); + /* off=1000:可见页 1..2 → 窗口 [0,3] */ + CHECK(mr_strip_window(&s, 1000, 4, &f, &l) == 4 && f == 0 && l == 3, + "window mid %d..%d", f, l); + /* off=max:可见页 4 → 窗口 [3,4] */ + CHECK(mr_strip_window(&s, 201520, 4, &f, &l) == 2 && f == 3 && l == 4, + "window bottom %d..%d", f, l); + /* 只用 1 个槽位:窗口收缩到焦点页 */ + CHECK(mr_strip_window(&s, 1000, 1, &f, &l) == 1 && f >= 1 && f <= 2 && f == l, + "window max_pages=1 %d..%d", f, l); + CHECK(mr_strip_window(&s, -100, 4, &f, &l) == 2 && f == 0 && l == 1, + "window negative off %d..%d", f, l); + + CHECK(mr_strip_at_end(&s, 0) == 0, "not at end at 0"); + CHECK(mr_strip_at_end(&s, 201519) == 0, "not at end just before max"); + CHECK(mr_strip_at_end(&s, 201520) == 1, "at end at max"); + CHECK(mr_strip_at_start(&s, 0) == 1, "at start 0"); + CHECK(mr_strip_at_start(&s, 1) == 0, "not at start 1"); + + /* 跳页落位:每页 offset 回读一致(末页夹到 max) */ + for (int i = 0; i < 5; i++) { + int off = mr_strip_offset_for_page(&s, i); + int back = mr_strip_page_at(&s, off); + CHECK(back == i, "roundtrip page %d -> off %d -> page %d", i, off, back); + } + CHECK(mr_strip_offset_for_page(&s, 4) == 2240, "last page offset = its top"); + + /* 短册(内容不足一屏):任何页都夹到 max=0 */ + fake_meta m2; + memset(&m2, 0, sizeof m2); + meta_page(&m2, 0, 800, 300, 1); /* 高 180 */ + meta_page(&m2, 1, 800, 300, 1); + mr_strip s2; + memset(&s2, 0, sizeof s2); + CHECK(mr_strip_build(&s2, 2, VW, VH, meta_cb, &m2) == 0, "build short"); + CHECK(mr_strip_total(&s2) == 360 && mr_strip_max_offset(&s2) == 0, "short totals"); + CHECK(mr_strip_offset_for_page(&s2, 1) == 0, "short last page clamps to 0"); + CHECK(mr_strip_at_end(&s2, 0) == 1 && mr_strip_at_start(&s2, 0) == 1, "short edges"); + mr_strip_free(&s2); + + /* 越界参数 */ + CHECK(mr_strip_page_h(&s, -1) == 0 && mr_strip_page_h(&s, 5) == 0, "page_h OOB"); + CHECK(mr_strip_top(&s, -1) == 0 && mr_strip_top(&s, 6) == 0, "top OOB"); + + mr_strip_free(&s); + CHECK(s.top == NULL && s.height == NULL && s.count == 0, "free clears"); + mr_strip_free(&s); +} + +static void test_build_contract(void) +{ + fake_meta m; + memset(&m, 0, sizeof m); + meta_page(&m, 0, 800, 1200, 1); + + mr_strip s; + memset(&s, 0, sizeof s); + CHECK(mr_strip_build(NULL, 1, VW, VH, meta_cb, &m) == -1, "NULL strip"); + CHECK(mr_strip_build(&s, 0, VW, VH, meta_cb, &m) == -1, "count 0 -> -1"); + CHECK(s.top == NULL && s.count == 0, "failed build clears"); + CHECK(mr_strip_build(&s, 1, 0, VH, meta_cb, &m) == -1, "vw 0 -> -1"); + CHECK(mr_strip_build(&s, 1, VW, 0, meta_cb, &m) == -1, "vh 0 -> -1"); + + /* meta 回调为 NULL → 全部按 2:3 占位 */ + CHECK(mr_strip_build(&s, 2, VW, VH, NULL, NULL) == 0, "NULL meta build"); + CHECK(mr_strip_page_h(&s, 0) == 720 && mr_strip_page_h(&s, 1) == 720, + "NULL meta fallback heights"); + mr_strip_free(&s); + + /* meta 返回失败(0)也按占位处理 */ + CHECK(mr_strip_build(&s, 1, VW, VH, meta_cb, &m) == 0, "rebuild after free"); + CHECK(mr_strip_page_h(&s, 0) == 720, "rebuild height"); + mr_strip_free(&s); + + /* 空排版查询全部安全 */ + mr_strip e; + memset(&e, 0, sizeof e); + CHECK(mr_strip_total(&e) == 0 && mr_strip_max_offset(&e) == 0, "empty totals"); + CHECK(mr_strip_page_at(&e, 0) == -1, "empty page_at"); + CHECK(mr_strip_offset_for_page(&e, 0) == -1, "empty offset_for_page"); + int f = 9, l = 9; + CHECK(mr_strip_window(&e, 0, 4, &f, &l) == 0 && l == -1, "empty window"); + CHECK(mr_strip_at_end(&e, 0) == 0 && mr_strip_at_start(&e, 0) == 0, "empty edges"); +} + +int main(void) +{ + test_single_page(); + test_multi_pages(); + test_build_contract(); + printf("%s: %d passed, %d failed\n", g_fail ? "FAIL" : "PASS", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/tuku/ui/reader/reader.c b/tuku/ui/reader/reader.c index 19f9b87..ae80fe2 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -1,27 +1,40 @@ -/* ui/reader/reader.c — 阅读器页(ACT_READER,全屏):左右翻页模式。 +/* ui/reader/reader.c — 阅读器页(ACT_READER,全屏):左右翻页 + 条漫连续滚动。 * - * 用户口径:优先流畅度,其次功能。翻页路径不解码、不重排: - * - 后台 pthread 解码窗口(当前 ±1 页),主循环只取已备好的图; - * - 跟手拖动只 blit 两张已缓存页图,松手后 meow_anim 一次短滑动吸附(150ms); - * - 只 flush 受影响区(本页=整屏内容区);翻页/解码/驻留耗时都 meow_log 打点。 + * 用户口径:优先流畅度,其次功能。两条阅读路径都不在翻页/滚动热路径上解码: + * - 后台 pthread 解码窗口(视口 ±1 页,总驻留 ≤ MR_SLOTS 张 downscale 后页图), + * 主循环只取已备好的图; + * - 翻页:跟手拖动只 blit 两张已缓存页图,松手后 meow_anim 一次短滑动吸附(150ms); + * - 条漫:meow_scroll 跟手 track + 松手 fling 惯性,按滚动位置懒解码视口 ±1 页; + * - 只 flush 受影响区(本页/整条可见区);解码/驻留耗时都 meow_log 打点。 * - * 页源:logic/reader/page_source 的 mr_book 契约(count/meta/open/close)。目录册 - * (文件夹即书)是第一个实现;zip 册与条漫只需另写 provider / 消费方式,本页零改动。 + * 页源:logic/reader/page_source 的 mr_book 契约(count/meta/open/close)。条漫排版用 + * meta() 不解码像素;解码入口走 SDK M12 预算解码 meow_img_load_file_dim(JPEG 解码期缩解, + * PNG/BMP 整幅解码后降采样——超大头先 meow_img_probe_file 预判,超过预算跳过并告警)。 * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 * + * 模式(M7):翻页/条漫是一等公民。阅读 chrome 的图标按钮一键切换;每册的模式与 last page + * 经 meow_config 持久化(键 reader.mode/page.<册标识>,见 logic/reader.c),下次打开同一册 + * 自动恢复。书库行角标由 logic/library.c 扫描时读取同一份状态(副标题后缀「续读 Pn」)。 + * + * 跳页:chrome 内 meow_slider 选目标页,松手后先把目标页解码进缓存再落位(不白屏不闪烁; + * 2.5s 超时则降级落位并提示)。 + * + * 册间衔接:末页前进 → 同父目录下一个含图册(书库自然序)的首页;首页后退 → 上一册末页; + * 无相邻册时停住并轻提示(不误触退出)。条漫在条顶/条底继续拉 MR_BOOK_PULL 像素松手触发。 + * * 手势(与 SDK 语义对齐,见 sdk/meow_ui_input.c touch_release): * - 左缘(x<24)向右划 = 系统返回(MEOW_SWIPE_LEFT)→ 不消费,app.c page_pop 退出; * - 屏底 28px 上滑 = 系统回桌面(MEOW_EV_HOME),SDK 不分发给 App,本页不起手; - * - 中部横拖 = 跟手翻页(本页用 app.c 路由来的原始 down/move 自己算位移); - * - tap 分区方向感知:屏左/右 1/3 = 上/下一页,中 1/3 = chrome 显隐;双击 = 放大。 - * - 物理键不映射(app.c 只记日志)。 + * - 翻页模式:中部横拖 = 跟手翻页;tap 分区方向感知(左/右 1/3 = 上/下一页,中 1/3 = + * chrome 显隐);双击 = 放大。 + * - 条漫模式:竖向拖动 = 整条滚动(fling 惯性);tap = chrome 显隐;双击/横向滑动不动作。 * * 节拍:app.c 的 on_tick 只转给 library_on_tick(不在本页 scope)。本页用一条常驻 - * meow_anim “ticker”(SDK 主循环每轮 meow_anim_tick)驱动结果收取与双击判定超时, - * 不新增常驻轮询线程,也不改 on_tick 路由。翻页动画本身也走同一节拍。 + * meow_anim “ticker”(SDK 主循环每轮 meow_anim_tick)驱动结果收取、跳页落位、续读节流 + * 保存与单击判定超时;翻页动画与 meow_scroll fling 也走同一节拍。 * - * 生命周期:on_enter 建状态 + 建 TopAppBar + 预取首页;on_leave 全量释放(停解码线程、 - * 释放全部页图/册索引/控件)。控件在 on_enter 建 → owner=ACT_READER(非根页防泄漏)。 + * 生命周期:on_enter 建状态 + 建 TopAppBar/滑块 + 打开册并恢复模式/页码;on_leave 先落盘 + * 续读状态,再全量释放(停解码线程、释放页图/册索引/条漫排版/滚动会话/控件)。 */ #include "tuku_internal.h" #include "ui/reader/reader.h" @@ -32,9 +45,11 @@ #include #include #include +#include -#define MR_WIN 3 /* 预解码窗口:当前 ±1 页 */ -#define MR_TOPBAR_H 56 +#define MR_SLOTS 4 /* 页图缓存槽上限(条漫视口±1 ≤4;翻页只用 3) */ +#define MR_TOP_BAR_H 56 +#define MR_SLIDER_H 40 #define MR_SLIDE_MS 150 /* 松手后一次短滑动吸附 */ #define MR_BOUNCE_MS 120 /* 未过阈值回弹 */ #define MR_DOUBLE_MS 240 /* 单击等待窗口(区分双击) */ @@ -43,6 +58,12 @@ #define MR_PAINT_MS 16 /* 跟手拖动重绘节流(≈60fps 上限) */ #define MR_DRAG_SLOP 8 /* 小于此位移算点击 */ #define MR_ZOOM_TEMP_CAP (6 * 1024 * 1024) /* 缩放临时缓冲软上限(>此值降倍率,保内存) */ +#define MR_BOOK_PULL 110 /* 条漫到顶/底后再拉这么远,松手进相邻册 */ +#define MR_JUMP_TIMEOUT_MS 2500 /* 目标页迟迟解不出来 → 降级落位并提示 */ +#define MR_CONT_SAVE_MS 1000 /* 续读状态落盘节流 */ +#define MR_TOAST_MS 1400 +#define MR_LOAD_RAW_WARN (12L * 1024 * 1024) /* 非缩解格式原始 RGBA 超此值提示残余风险 */ +#define MR_LOAD_RAW_CAP (40L * 1024 * 1024) /* 超此值直接跳过(防 OOM;页显示占位) */ #define MR_REQ_MAX 8 #define MR_RES_MAX 4 @@ -57,7 +78,8 @@ static mr_dir_book g_db; static mr_book g_book; static int g_count; /* 0=空册/不可读 */ static int g_open_err; -static int g_page; +static int g_page; /* 翻页模式当前页;条漫模式=最近落位页 */ +static int g_mode = MR_MODE_PAGED; static int g_vw, g_vh; /* 内容视口 = 全屏 */ static int g_fit = MR_FIT_WIDTH; @@ -65,18 +87,63 @@ static int g_zoom_pct = 100; static int g_pan_x, g_pan_y; static int g_chrome = 1; static meow_widget *g_tb; +static int g_act_mode = -1; /* TopAppBar 动作下标:模式切换 */ +static int g_act_jump = -1; /* 动作下标:跳页滑块 */ +static int g_act_fit = -1; /* 动作下标:fit 档循环 */ -/* ---- 页图缓存(≤3 张 downscale 后的图)---- */ +/* ---- 条漫:排版表 + 滚动会话 ---- */ +static mr_strip g_strip; +static int g_strip_ok; +static int g_woff; /* 条漫滚动偏移(px) */ +static meow_scroll *g_scroll; + +/* ---- 跳页(chrome 滑块 + 预解码落位) ---- */ +static meow_widget *g_slider; +static int g_jump_open; +static mr_jump g_jump; + +/* ---- 轻提示(册边界/模式切换/跳页) ---- */ +static char g_toast[64]; +static uint64_t g_toast_until; +static int g_title_page = -1; /* 标题里显示的页(条漫滚动停止后同步) */ + +/* ---- 续读落盘(节流) ---- */ +static int g_cont_dirty; +static int g_cont_last_page = -1; +static int g_cont_last_mode = -1; +static uint64_t g_cont_save_ms; + +/* ---- 当前解码窗(≤MR_SLOTS 页,按优先级排列) ---- */ +static int g_win_idx[MR_SLOTS]; +static int g_win_n; +static int g_fail_paint; /* 窗口内出现解码失败页 → tick 重绘错误占位 */ + +/* ---- 页图缓存(≤4 张 downscale 后的图)---- */ typedef struct page_slot { int idx; /* -1=空 */ meow_img *img; /* 已 downscale 的页图 */ int src_w, src_h; } page_slot; -static page_slot g_slot[MR_WIN]; +static page_slot g_slot[MR_SLOTS]; + +/* 解码失败页标记:避免对坏图/超大图无限重试,UI 显示「图片无法显示」而不是一直加载中。 */ +static unsigned char g_page_fail[(MR_BOOK_MAX_PAGES + 7) / 8]; + +static int page_failed(int idx) +{ + if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return 0; + return (g_page_fail[idx >> 3] >> (idx & 7)) & 1; +} + +static void page_mark_failed(int idx) +{ + if (idx < 0 || idx >= MR_BOOK_MAX_PAGES) return; + g_page_fail[idx >> 3] |= (unsigned char)(1u << (idx & 7)); +} /* ---- 后台解码队列 ---- */ typedef struct page_req { int idx; unsigned gen; } page_req; -typedef struct page_res { int idx; unsigned gen; meow_img *img; } page_res; +typedef struct page_res { int idx; unsigned gen; meow_img *img; int fail; } page_res; static pthread_t g_worker; static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; @@ -103,11 +170,14 @@ static int g_quitting; /* on_leave 期间抑制回调 */ /* ---- 跟手拖动 ---- */ static int g_dragging; /* 1=本次按下正在拖动 */ -static int g_drag_mode; /* 0=无 1=横滑翻页 2=放大平移 */ +static int g_drag_mode; /* 0=无 1=横滑翻页 2=放大平移 3=滑块 4=条漫滚动 */ static int g_drag_x0, g_drag_y0; static int g_drag_off; /* 当前页水平位移(px) */ static int g_drag_pan_x0, g_drag_pan_y0; static int g_touch_moved; /* 本次按下是否已产生 >slop 的位移 */ +static int g_wo_last_y; /* 条漫:上一次 move 的 y */ +static int g_wo_y0; /* 条漫:按下点 y(slop 判定) */ +static int g_wo_pull; /* 条漫:到边后继续拉的累计量(>0 往末页方向) */ /* ---- 单击延迟(双击判定窗口)---- */ static int g_tap_pending; @@ -116,16 +186,59 @@ static uint64_t g_tap_ms; static uint64_t g_last_tap_ms; /* 上一次点击(跨按下保持,双击判定用) */ static int g_last_tap_x, g_last_tap_y; +/* ---- 前向声明(互相调用:册切换/模式/落位) ---- */ +static int open_book(const char *dir, int start_page, int mode); +static int book_switch(int delta); +static void mode_set(int mode, int from_page); +static void toast(const char *msg); +static void slider_place(void); +static void update_title(void); +static void cont_touch(void); +static void cont_save_now(void); +static int current_page(void); + /* ============================================================ */ /* 页源解码回调(注入 logic 层的 mr_book 契约;可在后台线程调用) */ /* ============================================================ */ +/* 扩展名判断:JPEG 支持解码期缩解(M12),PNG/BMP 只能整幅解码后降采样。 */ +static int path_is_jpeg(const char *path) +{ + const char *dot = path ? strrchr(path, '.') : NULL; + if (!dot) return 0; + dot++; + return strcasecmp(dot, "jpg") == 0 || strcasecmp(dot, "jpeg") == 0; +} + static meow_img *reader_load_page(const char *path, void *ud, int maxdim) { (void)ud; - meow_img *im = meow_img_load_file(path); + if (!path || !path[0]) return NULL; + + /* 预算预判(M12):先读文件头拿原始尺寸,不解码像素。 + * - 非缩解格式(PNG/BMP)原始 RGBA 超过预算 → 跳过,避免真机 OOM(页面显示占位); + * - 超过告警线 → 记 WARN(残余风险,按 M12 报告口径如实提示)。 */ + int pw = 0, ph = 0, ch = 0; + if (meow_img_probe_file(path, &pw, &ph, &ch) && pw > 0 && ph > 0) { + long long raw = (long long)pw * (long long)ph * 4; + if (!path_is_jpeg(path)) { + if (raw > MR_LOAD_RAW_CAP) { + meow_log(MEOW_LOG_WARN, "reader decode skip %dx%d (raw %lldKB > cap): %s", + pw, ph, raw / 1024, path); + return NULL; + } + if (raw > MR_LOAD_RAW_WARN) + meow_log(MEOW_LOG_WARN, "reader decode large non-scalable %dx%d (%lldKB): %s", + pw, ph, raw / 1024, path); + } + } + + meow_img *im = meow_img_load_file_dim(path, maxdim); if (!im) return NULL; - meow_img *small = meow_img_downscale(im, maxdim); /* 大图先缩再驻留 */ - if (small) { meow_img_free(im); im = small; } + /* 兜底:M12 之前的格式/未知格式若没被缩到预算内,再降一次(保持驻留纪律)。 */ + if (maxdim > 0 && (meow_img_w(im) > maxdim || meow_img_h(im) > maxdim)) { + meow_img *small = meow_img_downscale(im, maxdim); + if (small) { meow_img_free(im); im = small; } + } return im; } @@ -154,7 +267,7 @@ static long mem_kb(const char *path, const char *key) static int resident_pages(void) { int n = 0; - for (int i = 0; i < MR_WIN; i++) + for (int i = 0; i < MR_SLOTS; i++) if (g_slot[i].img) n++; return n; } @@ -162,7 +275,7 @@ static int resident_pages(void) static long resident_bytes(void) { long b = 0; - for (int i = 0; i < MR_WIN; i++) + for (int i = 0; i < MR_SLOTS; i++) if (g_slot[i].img) b += (long)g_slot[i].src_w * g_slot[i].src_h * 4; return b; } @@ -174,20 +287,77 @@ static void log_mem(const char *tag) mem_kb("/proc/self/status", "VmRSS:"), resident_pages(), resident_bytes() / 1024); } +/* ============================================================ */ +/* 续读状态(逐册 last page + mode;写入节流,避免滚动时频繁写卡) */ +/* ============================================================ */ +static int current_page(void) +{ + if (g_count <= 0) return 0; + if (g_mode == MR_MODE_WEBTOON && g_strip_ok) + return mr_strip_page_at(&g_strip, g_woff); + return g_page; +} + +static void cont_save_now(void) +{ + if (!g_dir[0] || g_count <= 0) { g_cont_dirty = 0; return; } + int p = current_page(); + if (p == g_cont_last_page && g_mode == g_cont_last_mode) { g_cont_dirty = 0; return; } + mr_cont_save(g_dir, p, g_mode); + g_cont_last_page = p; + g_cont_last_mode = g_mode; + g_cont_dirty = 0; + meow_log(MEOW_LOG_INFO, "reader cont save page=%d/%d mode=%s", + p + 1, g_count, mr_mode_name(g_mode)); +} + +static void cont_touch(void) +{ + g_cont_dirty = 1; +} + +/* 只在滚动/翻页停下且距上次写盘超过 MR_CONT_SAVE_MS 时落盘。 */ +static void cont_save_throttled(void) +{ + if (!g_cont_dirty) return; + if (g_scroll && meow_scroll_active(g_scroll)) return; + if (g_dragging) return; + uint64_t now = meow_ui_time_ms(); + if (now - g_cont_save_ms < MR_CONT_SAVE_MS) return; + g_cont_save_ms = now; + cont_save_now(); +} + /* ============================================================ */ /* 页图缓存 */ /* ============================================================ */ +static int focus_page(void) +{ + if (g_count <= 0) return -1; + if (g_mode == MR_MODE_WEBTOON && g_strip_ok) + return mr_strip_page_at(&g_strip, g_woff + g_vh / 2); /* 视口中心页 */ + return g_page; +} + +static int in_window(int idx) +{ + if (g_count <= 0 || idx < 0 || idx >= g_count) return 0; + for (int i = 0; i < g_win_n; i++) + if (g_win_idx[i] == idx) return 1; + return 0; +} + static page_slot *slot_find(int idx) { if (idx < 0) return NULL; - for (int i = 0; i < MR_WIN; i++) + for (int i = 0; i < MR_SLOTS; i++) if (g_slot[i].idx == idx && g_slot[i].img) return &g_slot[i]; return NULL; } static void slot_clear(int i) { - if (i < 0 || i >= MR_WIN) return; + if (i < 0 || i >= MR_SLOTS) return; if (g_slot[i].img) meow_img_free(g_slot[i].img); g_slot[i].img = NULL; g_slot[i].idx = -1; @@ -196,18 +366,19 @@ static void slot_clear(int i) static void slots_free_all(void) { - for (int i = 0; i < MR_WIN; i++) slot_clear(i); + for (int i = 0; i < MR_SLOTS; i++) slot_clear(i); } -/* 放进缓存;缓存满时淘汰离当前页最远的一张。返回 1=已放入。 */ +/* 放进缓存;缓存满时淘汰离焦点页最远的一张。返回 1=已放入。 */ static int slot_put(int idx, meow_img *img) { if (!img || idx < 0) return 0; + int focus = focus_page(); int empty = -1, far = -1, far_d = -1; - for (int i = 0; i < MR_WIN; i++) { + for (int i = 0; i < MR_SLOTS; i++) { if (g_slot[i].idx == idx && g_slot[i].img) { meow_img_free(img); return 0; } if (!g_slot[i].img && empty < 0) empty = i; - int d = !g_slot[i].img ? -1 : abs(g_slot[i].idx - g_page); + int d = !g_slot[i].img ? -1 : abs(g_slot[i].idx - focus); if (d > far_d) { far_d = d; far = i; } } int i = empty >= 0 ? empty : far; @@ -220,12 +391,50 @@ static int slot_put(int idx, meow_img *img) return 1; } -/* 当前窗口:正常=当前±1;放大时只驻留当前页(给缩放临时缓冲让内存)。 */ -static int in_window(int idx) +/* ============================================================ */ +/* 解码窗计算(≤MR_SLOTS 页,按优先级:跳页目标 > 焦点 > 视口 ±1) */ +/* ============================================================ */ +static void win_reset(void) { - if (g_count <= 0 || idx < 0 || idx >= g_count) return 0; - if (g_zoom_pct > 100) return idx == g_page; - return idx >= g_page - 1 && idx <= g_page + 1; + g_win_n = 0; +} + +static void win_add(int idx) +{ + if (idx < 0 || idx >= g_count || g_win_n >= MR_SLOTS) return; + for (int i = 0; i < g_win_n; i++) + if (g_win_idx[i] == idx) return; + g_win_idx[g_win_n++] = idx; +} + +static void window_compute(void) +{ + win_reset(); + if (g_count <= 0) return; + + int jt = mr_jump_target(&g_jump); + if (jt >= 0) win_add(jt); /* 跳页目标优先,否则会被窗口淘汰 */ + + int focus = focus_page(); + win_add(focus); + + if (g_mode == MR_MODE_WEBTOON && g_strip_ok) { + int f = 0, l = 0; + int budget = MR_SLOTS - g_win_n; /* 可见±1;剩余槽位给视口 */ + if (budget < 1) budget = 1; + mr_strip_window(&g_strip, g_woff, budget, &f, &l); + for (int i = f; i <= l; i++) win_add(i); + /* 视口窗口可能因预算被截:再补焦点两侧各一页,保证滚动方向有前瞻 */ + for (int d = 1; d <= MR_SLOTS; d++) { + win_add(focus - d); + win_add(focus + d); + } + } else if (g_zoom_pct > 100) { + win_add(g_page); /* 放大:只驻留当前页,给缩放临时缓冲让内存 */ + } else { + win_add(g_page - 1); + win_add(g_page + 1); + } } /* ============================================================ */ @@ -243,7 +452,7 @@ static int req_queued(int idx) static void queue_page(int idx) { if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; - if (slot_find(idx)) return; + if (slot_find(idx) || page_failed(idx)) return; pthread_mutex_lock(&g_lock); if (!g_stop && !req_queued(idx) && g_req_n < MR_REQ_MAX) { g_req[g_req_tail].idx = idx; @@ -277,12 +486,13 @@ static void *reader_worker(void *arg) req.idx, (unsigned long long)dt, w, h, img ? "ok" : "fail"); pthread_mutex_lock(&g_lock); - if (img && !g_stop && g_res_n < MR_RES_MAX) { + if (!g_stop && g_res_n < MR_RES_MAX) { g_res[g_res_n].idx = req.idx; g_res[g_res_n].gen = req.gen; g_res[g_res_n].img = img; + g_res[g_res_n].fail = img ? 0 : 1; g_res_n++; - img = NULL; + img = NULL; /* 所有权已移交(失败时本就是 NULL) */ } pthread_mutex_unlock(&g_lock); if (img) meow_img_free(img); @@ -345,7 +555,13 @@ static int results_drain(void) int changed = 0; for (int i = 0; i < n; i++) { - if (got[i].gen == g_gen && in_window(got[i].idx) && !slot_find(got[i].idx)) { + if (got[i].gen != g_gen) { meow_img_free(got[i].img); continue; } + if (got[i].fail) { + page_mark_failed(got[i].idx); + if (in_window(got[i].idx)) g_fail_paint = 1; + continue; + } + if (in_window(got[i].idx) && !slot_find(got[i].idx)) { if (slot_put(got[i].idx, got[i].img)) changed = 1; } else { meow_img_free(got[i].img); @@ -354,16 +570,88 @@ static int results_drain(void) return changed; } -/* 窗口随当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 补齐缺失页(当前页优先)。 */ +/* 窗口随视口/当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 按优先级补页。 */ +/* 窗口随视口/当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 按优先级补页。 */ static void window_update(void) { - for (int i = 0; i < MR_WIN; i++) + window_compute(); + for (int i = 0; i < MR_SLOTS; i++) if (g_slot[i].idx >= 0 && !in_window(g_slot[i].idx)) slot_clear(i); queue_prune(); + for (int i = 0; i < g_win_n; i++) queue_page(g_win_idx[i]); +} - queue_page(g_page); - if (g_page - 1 >= 0) queue_page(g_page - 1); - if (g_page + 1 < g_count) queue_page(g_page + 1); +/* ============================================================ */ +/* 条漫排版 + 滚动会话 */ +/* ============================================================ */ +static int strip_meta_cb(void *ctx, int index, int *w, int *h) +{ + return mr_book_meta((const mr_book *)ctx, index, w, h); +} + +static int strip_ensure(void) +{ + if (g_strip_ok && g_strip.count == g_count && + g_strip.vw == g_vw && g_strip.vh == g_vh) + return 1; + mr_strip_free(&g_strip); + g_strip_ok = 0; + if (g_count <= 0) return 0; + uint64_t t0 = meow_ui_time_ms(); + if (mr_strip_build(&g_strip, g_count, g_vw, g_vh, strip_meta_cb, &g_book) != 0) { + meow_log(MEOW_LOG_WARN, "reader strip layout failed pages=%d", g_count); + return 0; + } + g_strip_ok = 1; + meow_log(MEOW_LOG_INFO, "reader strip layout pages=%d total=%d ms=%llu", + g_count, mr_strip_total(&g_strip), + (unsigned long long)(meow_ui_time_ms() - t0)); + return 1; +} + +static int wo_max(void *ctx) +{ + (void)ctx; + return mr_strip_max_offset(&g_strip); +} + +static int wo_get(void *ctx) +{ + (void)ctx; + return g_woff; +} + +static void wo_set(void *ctx, int off) +{ + (void)ctx; + int m = mr_strip_max_offset(&g_strip); + if (off < 0) off = 0; + if (off > m) off = m; + if (off == g_woff) return; + g_woff = off; + window_update(); + cont_touch(); + /* 重绘由调用方节流:拖动走 drag_paint(),fling 走 wo_redraw()。 */ +} + +static void wo_redraw(void *ctx) +{ + (void)ctx; + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void scroll_ensure(void) +{ + if (g_scroll) return; + g_scroll = meow_scroll_create(wo_set, wo_max, wo_get, wo_redraw, NULL); + if (!g_scroll) meow_log(MEOW_LOG_WARN, "reader scroll session unavailable (pool full)"); +} + +static void scroll_free(void) +{ + if (!g_scroll) return; + meow_scroll_free(g_scroll); + g_scroll = NULL; } /* ============================================================ */ @@ -427,6 +715,50 @@ static void draw_page_at(int idx, int off_x, int off_y) else meow_ui_img_scaled(x, y, w, h, s->img); } +/* 条漫:按滚动偏移画可见页。典型 2:3 页的解码尺寸与显示尺寸相等 → 原生 blit; + * 其余按最近邻缩放(缩放在 SDK 内部申请目标尺寸临时缓冲,单页峰值受控)。 */ +static void draw_strip(const meow_theme *th) +{ + if (!g_strip_ok) { + meow_ui_text_center(g_vw / 2, g_vh / 2, "正在排版…", th->font_body, th->onSurfaceVariant); + return; + } + int first = mr_strip_page_at(&g_strip, g_woff); + int last = mr_strip_page_at(&g_strip, g_woff + g_vh - 1); + for (int i = first; i <= last; i++) { + int y = mr_strip_top(&g_strip, i) - g_woff; + int h = mr_strip_page_h(&g_strip, i); + if (h <= 0) continue; + page_slot *s = slot_find(i); + if (s) { + if (s->src_w == g_vw && s->src_h == h) meow_ui_img(0, y, s->img); + else meow_ui_img_scaled(0, y, g_vw, h, s->img); + continue; + } + /* 未就绪:只填可见部分(长页不整块清屏),给一帧占位而不是白屏。 */ + int y0 = y < 0 ? 0 : y; + int y1 = y + h; + if (y1 > g_vh) y1 = g_vh; + if (y1 <= y0) continue; + meow_ui_fill_rect(0, y0, g_vw, y1 - y0, th->surfaceContainer); + if (y1 - y0 >= 48) + meow_ui_text_center(g_vw / 2, y0 + (y1 - y0) / 2 - 10, + page_failed(i) ? "图片无法显示" : "加载中…", + th->font_body, th->onSurfaceVariant); + } +} + +static void draw_toast(const meow_theme *th) +{ + if (!g_toast[0] || meow_ui_time_ms() >= g_toast_until) return; + int tw = meow_ui_text_width(g_toast, th->font_body) + 40; + if (tw > g_vw - 16) tw = g_vw - 16; + int x = (g_vw - tw) / 2; + int y = g_vh - 120; + meow_ui_fill_rect_alpha(x, y, tw, 44, th->surfaceContainerHighest, 235); + meow_ui_text_center(g_vw / 2, y + 12, g_toast, th->font_body, th->onSurface); +} + static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) { (void)v; (void)ctx; @@ -444,10 +776,17 @@ static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) return; } + if (g_mode == MR_MODE_WEBTOON) { + draw_strip(th); + draw_toast(th); + return; + } + if (g_slide_delta != 0) { /* 动画帧:进场页从 from 滑到 0,当前页同步让位(只 blit 两张已缓存页图)。 */ draw_page_at(g_page, g_anim_v - g_slide_from, 0); draw_page_at(g_page + g_slide_delta, g_anim_v, 0); + draw_toast(th); return; } @@ -461,13 +800,17 @@ static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) draw_page_at(target, g_drag_off + from, 0); } draw_page_at(g_page, g_drag_off, 0); + draw_toast(th); return; } draw_page_at(g_page, 0, 0); if (!slot_find(g_page)) { - meow_ui_text_center(w / 2, h / 2, "加载中…", th->font_body, th->onSurfaceVariant); + meow_ui_text_center(w / 2, h / 2, + page_failed(g_page) ? "图片无法显示" : "加载中…", + th->font_body, th->onSurfaceVariant); } + draw_toast(th); } /* ============================================================ */ @@ -479,16 +822,66 @@ static void update_title(void) static const char *const fit_name[MR_FIT_COUNT] = { "适宽", "适高", "原始" }; const char *fit = (g_fit >= 0 && g_fit < MR_FIT_COUNT) ? fit_name[g_fit] : "适宽"; char buf[64]; - if (g_count > 0) snprintf(buf, sizeof buf, "%d / %d %s", g_page + 1, g_count, fit); - else snprintf(buf, sizeof buf, "0 / 0"); + if (g_count > 0) { + if (g_mode == MR_MODE_WEBTOON) + snprintf(buf, sizeof buf, "%d / %d 条漫", current_page() + 1, g_count); + else + snprintf(buf, sizeof buf, "%d / %d 翻页 %s", g_page + 1, g_count, fit); + } else { + snprintf(buf, sizeof buf, "0 / 0"); + } meow_topappbar_set_title(g_tb, buf); + g_title_page = (g_count > 0) ? current_page() : -1; +} + +static void toast(const char *msg) +{ + snprintf(g_toast, sizeof g_toast, "%s", msg ? msg : ""); + g_toast_until = meow_ui_time_ms() + MR_TOAST_MS; + meow_ui_invalidate(0, g_vh - 140, g_vw, 140); +} + +static void slider_place(void) +{ + if (!g_slider) return; + int visible = g_chrome && g_jump_open && g_count > 1; + if (visible) + meow_widget_set_rect(g_slider, 24, MR_TOP_BAR_H + 4, g_vw - 48, MR_SLIDER_H); + else + meow_widget_set_rect(g_slider, 0, -(MR_TOP_BAR_H + MR_SLIDER_H + 8), + g_vw - 48, MR_SLIDER_H); +} + +static void slider_build(void) +{ + if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } + if (g_count <= 1) return; + int val = current_page() + 1; + if (val < 1) val = 1; + if (val > g_count) val = g_count; + g_slider = meow_slider_create(0, 0, g_vw - 48, 1, g_count, val); + slider_place(); +} + +static void slider_sync(void) +{ + if (!g_slider || g_count <= 0) return; + int v = current_page() + 1; + if (v < 1) v = 1; + if (v > g_count) v = g_count; + meow_slider_set(g_slider, v); } static void set_fit(int fit) { + if (g_mode == MR_MODE_WEBTOON) { + toast("条漫固定适宽"); + return; + } g_fit = (fit < 0 || fit >= MR_FIT_COUNT) ? MR_FIT_WIDTH : fit; g_zoom_pct = 100; g_pan_x = g_pan_y = 0; + window_update(); update_title(); meow_ui_invalidate(0, 0, g_vw, g_vh); } @@ -498,8 +891,9 @@ static void set_chrome(int on) on = on ? 1 : 0; if (g_chrome == on) return; g_chrome = on; - if (g_tb) meow_widget_set_rect(g_tb, 0, on ? 0 : -MR_TOPBAR_H, g_vw, MR_TOPBAR_H); - meow_ui_invalidate(0, 0, g_vw, MR_TOPBAR_H); + if (g_tb) meow_widget_set_rect(g_tb, 0, on ? 0 : -MR_TOP_BAR_H, g_vw, MR_TOP_BAR_H); + slider_place(); + meow_ui_invalidate(0, 0, g_vw, MR_TOP_BAR_H + MR_SLIDER_H + 8); } /* 平移(dx>0 = 内容右移);放大或原始档溢出时可用。 */ @@ -521,6 +915,8 @@ static void commit_page(int target) g_pan_x = g_pan_y = 0; window_update(); update_title(); + slider_sync(); + cont_touch(); meow_ui_invalidate(0, 0, g_vw, g_vh); } @@ -605,12 +1001,15 @@ static void start_bounce(int from_off) if (!g_bounce) { g_drag_off = 0; g_drag_mode = 0; meow_ui_invalidate(0, 0, g_vw, g_vh); } } -/* 点击触发的翻页(无跟手位移)。 */ +/* 点击触发的翻页(无跟手位移)。首/末页再前进 → 相邻册(无相邻册则轻提示)。 */ static void page_flip(int delta) { if (delta == 0 || g_count <= 0) return; int target = mr_nav_clamp_page(g_page + delta, g_count); - if (target == g_page) return; /* 首/末页不动 */ + if (target == g_page) { /* 首/末页:册间衔接 */ + book_switch(delta > 0 ? 1 : -1); + return; + } if (g_snap || g_bounce) return; /* 短动画中忽略连点 */ if (g_zoom_pct != 100) { /* 翻页回到基准缩放 */ g_zoom_pct = 100; @@ -630,6 +1029,234 @@ static void page_flip(int delta) } } +/* ============================================================ */ +/* 跳页(chrome 滑块):目标页预解码完成后落位 */ +/* ============================================================ */ +static void jump_land(int page, int timed_out) +{ + if (g_count <= 0) return; + page = mr_nav_clamp_page(page, g_count); + if (page < 0) page = 0; + if (g_mode == MR_MODE_WEBTOON && g_strip_ok) { + if (g_scroll) meow_scroll_stop(g_scroll); + g_woff = mr_strip_offset_for_page(&g_strip, page); + g_page = page; + } else { + g_page = page; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + } + slider_sync(); + window_update(); + update_title(); + cont_touch(); + meow_ui_invalidate(0, 0, g_vw, g_vh); + meow_log(MEOW_LOG_INFO, "reader jump land page=%d/%d%s", + page + 1, g_count, timed_out ? " (timeout)" : ""); + if (timed_out) toast("页面解码较慢,已跳到目标页"); + else if (page_failed(page)) toast("目标页无法显示"); +} + +static void jump_commit_from_slider(void) +{ + if (!g_slider || g_count <= 0) return; + int page = meow_slider_get(g_slider) - 1; + g_jump_open = 0; + slider_place(); + if (page < 0) page = 0; + if (page >= g_count) page = g_count - 1; + if (page == current_page()) { /* 原地:只关面板 */ + meow_ui_invalidate(0, 0, g_vw, MR_TOP_BAR_H + MR_SLIDER_H + 8); + return; + } + if (slot_find(page)) { /* 已缓存:直接落位 */ + jump_land(page, 0); + return; + } + mr_jump_begin(&g_jump, page, g_count, meow_ui_time_ms()); + window_update(); /* 目标页进解码窗(最高优先级) */ + char msg[48]; + snprintf(msg, sizeof msg, "跳转到 P%d…", page + 1); + toast(msg); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void jump_ui_toggle(void) +{ + if (g_count <= 1) { toast("只有一页,无需跳转"); return; } + g_jump_open = !g_jump_open; + if (g_jump_open) { + if (!g_slider) slider_build(); + slider_sync(); + } + slider_place(); + meow_ui_invalidate(0, 0, g_vw, MR_TOP_BAR_H + MR_SLIDER_H + 8); +} + +static void slider_scrub(int x) +{ + if (!g_slider || g_count <= 1) return; + int sx = 24, sw = g_vw - 48; + if (sw < 1) sw = 1; + int v = 1 + (int)((long long)(x - sx) * (g_count - 1) / sw); + if (v < 1) v = 1; + if (v > g_count) v = g_count; + meow_slider_set(g_slider, v); + meow_ui_invalidate(0, MR_TOP_BAR_H, g_vw, MR_SLIDER_H + 8); +} + +/* ============================================================ */ +/* 模式切换(翻页 ⇄ 条漫) */ +/* ============================================================ */ +static void mode_set(int mode, int from_page) +{ + if (!mr_mode_valid(mode) || mode == g_mode) return; + int page = from_page >= 0 ? from_page : current_page(); + page = mr_nav_clamp_page(page, g_count); + if (page < 0) page = 0; + + if (mode == MR_MODE_WEBTOON) { + if (!strip_ensure()) { toast("条漫排版失败,保持翻页"); return; } + scroll_ensure(); + g_page = page; + g_woff = mr_strip_offset_for_page(&g_strip, page); + if (g_scroll) meow_scroll_stop(g_scroll); + } else { + g_page = page; + if (g_scroll) { scroll_free(); } + mr_strip_free(&g_strip); + g_strip_ok = 0; + g_woff = 0; + } + g_mode = mode; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + slider_sync(); + window_update(); + update_title(); + cont_touch(); + cont_save_now(); /* 模式切换立即记忆 */ + meow_ui_invalidate(0, 0, g_vw, g_vh); + meow_log(MEOW_LOG_INFO, "reader mode=%s page=%d/%d", + mr_mode_name(g_mode), current_page() + 1, g_count); + toast(mode == MR_MODE_WEBTOON ? "条漫模式 · 连续滚动" : "翻页模式"); +} + +/* ============================================================ */ +/* 册间衔接 / 打开册 */ +/* ============================================================ */ +static void open_book_reset_state(void) +{ + worker_stop(); + scroll_free(); + mr_strip_free(&g_strip); + g_strip_ok = 0; + slots_free_all(); + mr_book_destroy(&g_book); + mr_dir_book_free(&g_db); + if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } + g_gen++; /* 作废旧册的异步结果 */ + g_count = 0; + g_open_err = 0; + g_page = 0; + g_woff = 0; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + g_cont_dirty = 0; + g_cont_last_page = -1; + g_cont_last_mode = -1; + g_win_n = 0; + g_fail_paint = 0; + memset(g_page_fail, 0, sizeof g_page_fail); + g_jump_open = 0; + mr_jump_cancel(&g_jump); + g_toast[0] = '\0'; + g_title_page = -1; +} + +/* 打开一本册并恢复模式/起始页。start_page<0 = 末页(上一册衔接)。1=成功。 */ +static int open_book(const char *dir, int start_page, int mode) +{ + if (!dir || !dir[0]) return 0; + open_book_reset_state(); + snprintf(g_dir, sizeof g_dir, "%s", dir); + g_mode = mr_mode_valid(mode) ? mode : MR_MODE_PAGED; + + int maxdim = g_vw > g_vh ? g_vw : g_vh; + int n = mr_dir_book_init(&g_db, g_dir, maxdim, + reader_load_page, reader_unload_page, NULL); + if (n < 0) { + mr_dir_book_bind(&g_book, NULL); + g_open_err = 1; + worker_start(); + update_title(); + meow_log(MEOW_LOG_WARN, "reader open failed: %s", g_dir); + meow_ui_invalidate(0, 0, g_vw, g_vh); + return 0; + } + g_count = n; + mr_dir_book_bind(&g_book, g_count > 0 ? &g_db : NULL); + + int page = start_page < 0 ? g_count - 1 : start_page; + page = mr_nav_clamp_page(page, g_count); + if (page < 0) page = 0; + + if (g_mode == MR_MODE_WEBTOON) { + if (!strip_ensure()) { /* 排版失败:降级翻页,不白屏 */ + meow_log(MEOW_LOG_WARN, "reader strip unavailable, fallback paged"); + g_mode = MR_MODE_PAGED; + } + } + if (g_mode == MR_MODE_WEBTOON) { + scroll_ensure(); + g_page = page; + g_woff = mr_strip_offset_for_page(&g_strip, page); + } else { + g_page = page; + g_woff = 0; + } + + worker_start(); + if (!g_worker_on && g_count > 0) { + /* 线程创建失败:退化为主线程同步解起始页(保证不是纯白屏)。 */ + meow_img *first = mr_book_open(&g_book, g_page); + if (first) slot_put(g_page, first); + meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page"); + } + slider_build(); + window_update(); /* 起始页优先进队 */ + update_title(); + cont_touch(); + meow_ui_invalidate(0, 0, g_vw, g_vh); + meow_log(MEOW_LOG_INFO, "reader book=%s pages=%d mode=%s page=%d", + g_dir, g_count, mr_mode_name(g_mode), current_page() + 1); + return 1; +} + +static int book_switch(int delta) +{ + if (delta == 0) return 0; + if (g_count <= 0) return 0; + char next[MR_PATH_MAX]; + int r = mr_book_adjacent(g_dir, delta > 0 ? 1 : -1, next, (int)sizeof next); + if (r != 1) { + toast(delta > 0 ? "已是最后一册" : "已是第一册"); + return 0; + } + cont_save_now(); /* 先记忆当前册 */ + int mode = mr_cont_get_mode(next, g_mode); /* 相邻册自己的模式;没有则沿用当前 */ + if (!open_book(next, delta > 0 ? 0 : -1, mode)) { + toast("无法打开相邻册"); + return 0; + } + const char *base = strrchr(g_dir, '/'); + char msg[64]; + snprintf(msg, sizeof msg, "%s · %s", delta > 0 ? "下一册" : "上一册", + (base && base[1]) ? base + 1 : g_dir); + toast(msg); + return 1; +} + /* ============================================================ */ /* 跟手拖动 */ /* ============================================================ */ @@ -659,7 +1286,12 @@ static void drag_finish(void) int rtl = mr_reader_get_direction() == MR_DIR_RTL; int delta = mr_nav_swipe_delta(rtl, dx); int target = mr_nav_clamp_page(g_page + delta, g_count); - if (target != g_page && mr_nav_commit(dx, g_vw)) { + if (mr_nav_commit(dx, g_vw)) { + if (target == g_page) { /* 首/末页越界:进相邻册 */ + book_switch(delta > 0 ? 1 : -1); + meow_ui_invalidate(0, 0, g_vw, g_vh); + return; + } g_flip_t0 = meow_ui_time_ms(); if (slot_find(target) && start_slide(delta, dx)) { meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d hit", g_page + 1, target + 1); @@ -675,15 +1307,49 @@ static void drag_finish(void) } } +/* 条漫松手:到条顶/条底后再拉过阈值 → 进相邻册;否则起 fling 惯性。 */ +static void webtoon_release(void) +{ + int pull = g_wo_pull; + g_wo_pull = 0; + g_dragging = 0; + g_drag_mode = 0; + if (pull >= MR_BOOK_PULL && book_switch(1)) return; + if (pull <= -MR_BOOK_PULL && book_switch(-1)) return; + if (g_scroll) meow_scroll_fling(g_scroll); + cont_touch(); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + void reader_on_touch_down(int x, int y) { if (!g_ctrl.active || g_quitting) return; if (g_count <= 0) return; + g_touch_moved = 0; /* 每次按下都是新手势(chrome 抬手也不残留) */ if (y >= g_vh - 28) return; /* 屏底热区:系统回桌面,勿劫持 */ if (x < 24) return; /* 左缘:系统返回手势,勿劫持 */ - if (g_chrome && y < MR_TOPBAR_H) return; /* chrome 上的点击交给 tap */ + if (g_chrome && y < MR_TOP_BAR_H) + return; /* 顶栏点击交给 tap(返回/模式/跳页/fit) */ + if (g_chrome && g_jump_open && g_slider && y < MR_TOP_BAR_H + 4 + MR_SLIDER_H) { + g_dragging = 1; + g_drag_mode = 3; + g_touch_moved = 1; + slider_scrub(x); + return; + } if (g_snap || g_bounce) return; /* 短动画中不接新手势 */ + if (g_mode == MR_MODE_WEBTOON) { + g_dragging = 1; + g_drag_mode = 4; + g_wo_y0 = y; + g_wo_last_y = y; + g_wo_pull = 0; + g_tap_pending = 0; /* 条漫没有双击缩放:单击立即生效 */ + if (g_scroll) meow_scroll_stop(g_scroll); + return; + } + g_dragging = 1; g_drag_mode = pan_mode() ? 2 : 1; g_drag_x0 = x; @@ -691,12 +1357,44 @@ void reader_on_touch_down(int x, int y) g_drag_off = 0; g_drag_pan_x0 = g_pan_x; g_drag_pan_y0 = g_pan_y; - g_touch_moved = 0; } void reader_on_touch_move(int x, int y) { if (!g_ctrl.active || !g_dragging || g_quitting) return; + + if (g_drag_mode == 3) { /* 跳页滑块拖动 */ + slider_scrub(x); + return; + } + + if (g_drag_mode == 4) { /* 条漫:整条滚动 */ + int dy = g_wo_last_y - y; /* 手指上移 → 内容上滚 */ + g_wo_last_y = y; + if (!g_touch_moved && abs(y - g_wo_y0) >= MR_DRAG_SLOP) g_touch_moved = 1; + if (!g_scroll) return; + int maxo = mr_strip_max_offset(&g_strip); + int before = g_woff; + meow_scroll_track(g_scroll, dy, meow_ui_time_ms()); + int after = g_woff; + /* 到边后继续同向拉:累计「越界意图」,松手据此进相邻册。 + * 到底后手指继续上移(dy>0)才是「前进」;到顶后继续下移(dy<0)是「后退」。 + * 内容不足一屏(maxo==0)时两向都可拉:按手指方向累计。 */ + int at_bottom = (before >= maxo && after >= maxo); + int at_top = (before <= 0 && after <= 0); + if (at_bottom && at_top) { + g_wo_pull += dy; + } else if (at_bottom) { + if (dy > 0) g_wo_pull += dy; + } else if (at_top) { + if (dy < 0) g_wo_pull += dy; + } else { + g_wo_pull = 0; + } + drag_paint(); + return; + } + int dx = x - g_drag_x0; int dy = y - g_drag_y0; int adx = abs(dx), ady = abs(dy); @@ -744,10 +1442,15 @@ static void tap_topbar(int x, int y) if (!g_tb) return; int hit = meow_topappbar_hit(g_tb, x, y); if (hit == MEOW_TOPBAR_BACK) { + cont_save_now(); meow_view_page_pop(); return; } - if (hit >= 0) set_fit(mr_nav_fit_next(g_fit)); + if (hit < 0) return; + if (hit == g_act_mode) { mode_set(g_mode == MR_MODE_PAGED ? MR_MODE_WEBTOON + : MR_MODE_PAGED, -1); return; } + if (hit == g_act_jump) { jump_ui_toggle(); return; } + if (hit == g_act_fit) { set_fit(mr_nav_fit_next(g_fit)); return; } } static void tap_content(int x, int y) @@ -770,6 +1473,10 @@ static void tap_content(int x, int y) static void tap_double(int x, int y) { if (g_count <= 0) return; + if (g_mode == MR_MODE_WEBTOON) { /* 条漫不做缩放(保持适宽/内存纪律) */ + set_chrome(!g_chrome); + return; + } if (g_zoom_pct > 100) { g_zoom_pct = 100; g_pan_x = g_pan_y = 0; @@ -794,7 +1501,7 @@ static void tap_flush(void) if (!g_tap_pending) return; g_tap_pending = 0; int x = g_tap_x, y = g_tap_y; - if (g_chrome && y >= 0 && y < MR_TOPBAR_H) tap_topbar(x, y); + if (g_chrome && y >= 0 && y < MR_TOP_BAR_H) tap_topbar(x, y); else tap_content(x, y); } @@ -802,6 +1509,35 @@ void reader_on_tap(int x, int y) { if (!g_ctrl.active || g_quitting) return; + /* 跳页滑块松手:落位到目标页(目标页预解码后由 tick 落位)。 */ + if (g_drag_mode == 3) { + g_dragging = 0; + g_drag_mode = 0; + g_touch_moved = 0; + jump_commit_from_slider(); + return; + } + + if (g_mode == MR_MODE_WEBTOON) { + if (g_dragging) { + if (g_touch_moved) { + g_touch_moved = 0; + webtoon_release(); + return; + } + g_dragging = 0; + g_drag_mode = 0; + } else if (g_touch_moved) { + g_touch_moved = 0; /* 竖向拖动被 SDK 报成 TAP:忽略 */ + return; + } + g_touch_moved = 0; + if (g_chrome && y >= 0 && y < MR_TOP_BAR_H) { tap_topbar(x, y); return; } + if (g_jump_open && g_slider && y < MR_TOP_BAR_H + 4 + MR_SLIDER_H) return; + set_chrome(!g_chrome); + return; + } + /* 跟手手势收尾:移动过就按拖动处理(过阈值翻页 / 否则回弹),不再当点击。 */ if (g_dragging) { if (g_touch_moved) { @@ -819,7 +1555,7 @@ void reader_on_tap(int x, int y) g_touch_moved = 0; /* chrome 上的点击即时生效,不参与双击判定。 */ - if (g_chrome && y >= 0 && y < MR_TOPBAR_H) { + if (g_chrome && y >= 0 && y < MR_TOP_BAR_H) { g_tap_pending = 0; g_last_tap_ms = 0; tap_topbar(x, y); @@ -846,7 +1582,7 @@ void reader_on_tap(int x, int y) } /* ============================================================ */ -/* 节拍:收结果 + 单击超时 */ +/* 节拍:收结果 + 跳页落位 + 提示超时 + 续读落盘 */ /* ============================================================ */ static void reader_tick(void) { @@ -855,11 +1591,36 @@ static void reader_tick(void) if (now - g_last_tick < MR_TICK_MS) return; g_last_tick = now; - int had_current = slot_find(g_page) != NULL; - if (results_drain() && !had_current && slot_find(g_page) && !g_dragging) + int had_current = slot_find(current_page()) != NULL; + if (results_drain() && !had_current && slot_find(current_page()) && !g_dragging) meow_ui_invalidate(0, 0, g_vw, g_vh); + if (g_fail_paint) { + g_fail_paint = 0; + if (!g_dragging) meow_ui_invalidate(0, 0, g_vw, g_vh); + } if (g_tap_pending && now - g_tap_ms >= MR_DOUBLE_MS) tap_flush(); + + if (mr_jump_active(&g_jump)) { + int target = mr_jump_target(&g_jump); + int have = target >= 0 && (slot_find(target) != NULL || page_failed(target)); + int timed_out = 0; + if (mr_jump_poll(&g_jump, have, now, MR_JUMP_TIMEOUT_MS, &timed_out)) + jump_land(target, timed_out); + } + + if (g_toast[0] && now >= g_toast_until) { + g_toast[0] = '\0'; + meow_ui_invalidate(0, g_vh - 140, g_vw, 140); + } + + /* 条漫滚动停止后同步标题页码(滚动中不每帧更新顶栏文字)。 */ + if (g_mode == MR_MODE_WEBTOON && !g_dragging && + (!g_scroll || !meow_scroll_active(g_scroll))) { + if (current_page() != g_title_page) update_title(); + } + + cont_save_throttled(); } static void ticker_update(meow_anim *a, float value, void *ctx) @@ -901,8 +1662,22 @@ static int reader_event(meow_view *v, int ev, int a, int b, void *ctx) g_drag_mode = 0; g_drag_off = 0; g_touch_moved = 0; + g_wo_pull = 0; + cont_save_now(); /* 离开前记忆 */ return 0; } + if (g_drag_mode == 3) { /* 滑块拖动被 SDK 报成 SWIPE:落位 */ + g_dragging = 0; + g_drag_mode = 0; + g_touch_moved = 0; + jump_commit_from_slider(); + return 1; + } + if (g_mode == MR_MODE_WEBTOON) { + /* 条漫:竖向滑动 = 滚动收尾(fling/册边界);横向滑动不翻页,统一消费。 */ + if (g_dragging) webtoon_release(); + return 1; + } if (a == MEOW_SWIPE_RIGHT) { if (g_dragging) { drag_finish(); return 1; } if (g_count <= 0) return 1; @@ -930,6 +1705,7 @@ static void reader_state_reset(void) g_count = 0; g_open_err = 0; g_page = 0; + g_mode = MR_MODE_PAGED; g_fit = MR_FIT_WIDTH; g_zoom_pct = 100; g_pan_x = g_pan_y = 0; @@ -941,6 +1717,17 @@ static void reader_state_reset(void) g_drag_mode = 0; g_drag_off = 0; g_touch_moved = 0; + g_wo_pull = 0; + g_woff = 0; + g_strip_ok = 0; + g_jump_open = 0; + mr_jump_cancel(&g_jump); + g_toast[0] = '\0'; + g_title_page = -1; + g_cont_dirty = 0; + g_cont_last_page = -1; + g_cont_last_mode = -1; + g_win_n = 0; g_tap_pending = 0; g_last_tap_ms = 0; g_quitting = 0; @@ -969,43 +1756,31 @@ static void reader_page_enter(meow_view *v, void *ctx) g_last_paint = 0; /* 非根页:控件在 on_enter 建 → owner=ACT_READER(§6.1 跨页泄漏防线)。 */ - if (!g_tb) g_tb = meow_topappbar_create(0, 0, g_vw, MR_TOPBAR_H, ""); - if (g_tb) { - meow_topappbar_set_back(g_tb, 1); - int ai = meow_topappbar_add_action(g_tb, ""); - if (ai >= 0) meow_topappbar_set_item_icon(g_tb, ai, MEOW_ICON_REFRESH); - meow_widget_set_rect(g_tb, 0, 0, g_vw, MR_TOPBAR_H); + if (!g_tb) { + g_tb = meow_topappbar_create(0, 0, g_vw, MR_TOP_BAR_H, ""); + if (g_tb) { + meow_topappbar_set_back(g_tb, 1); + g_act_mode = meow_topappbar_add_action(g_tb, "模式"); + if (g_act_mode >= 0) meow_topappbar_set_item_icon(g_tb, g_act_mode, MEOW_ICON_QUEUE_MUSIC); + g_act_jump = meow_topappbar_add_action(g_tb, "跳页"); + if (g_act_jump >= 0) meow_topappbar_set_item_icon(g_tb, g_act_jump, MEOW_ICON_SEARCH); + g_act_fit = meow_topappbar_add_action(g_tb, "适宽"); + if (g_act_fit >= 0) meow_topappbar_set_item_icon(g_tb, g_act_fit, MEOW_ICON_REFRESH); + } } + if (g_tb) meow_widget_set_rect(g_tb, 0, 0, g_vw, MR_TOP_BAR_H); - /* 页源:目录册(文件夹即书)。maxdim 取屏幕长边,页图驻留 ≈ 屏宽×屏高×4。 */ - int maxdim = g_vw > g_vh ? g_vw : g_vh; - int n = mr_dir_book_init(&g_db, g_dir, maxdim, - reader_load_page, reader_unload_page, NULL); - if (n < 0) { - g_open_err = 1; - meow_log(MEOW_LOG_WARN, "reader open failed: %s", g_dir); - } else { - g_count = n; - } - mr_dir_book_bind(&g_book, g_count > 0 ? &g_db : NULL); + /* 打开册:恢复该册上次的模式与页码(无记录 → 翻页模式第 1 页)。 */ + int mode = mr_cont_get_mode(g_dir, MR_MODE_PAGED); + int page = mr_cont_get_page(g_dir, 0); + open_book(g_dir, page, mode); - worker_start(); - if (!g_worker_on && g_count > 0) { - /* 线程创建失败:退化为主线程同步解第一页(保证不是纯白屏)。 */ - meow_img *first = mr_book_open(&g_book, 0); - if (first) slot_put(0, first); - meow_log(MEOW_LOG_WARN, "reader worker unavailable, sync first page"); - } - window_update(); /* 当前页优先进队 */ ticker_start(); - update_title(); - - meow_log(MEOW_LOG_INFO, "reader enter dir=%s pages=%d fit=%d", g_dir, g_count, g_fit); log_mem("enter"); mr_nav_report(); meow_ui_paint_full(); - /* 首页解码是异步的:先给一帧文字/页码,结果在 tick 里落位。 */ + /* 起始页解码是异步的:先给一帧文字/页码,结果在 tick 里落位。 */ reader_tick(); } @@ -1015,20 +1790,30 @@ static void reader_page_leave(meow_view *v, void *ctx) ((mr_page_ctrl *)ctx)->active = 0; g_quitting = 1; + cont_save_now(); /* 离开前把最后一页落盘 */ + if (g_snap) { meow_anim_cancel(g_snap); g_snap = NULL; } if (g_bounce) { meow_anim_cancel(g_bounce); g_bounce = NULL; } if (g_ticker) { meow_anim_cancel(g_ticker); g_ticker = NULL; } worker_stop(); /* 先停线程,再释放册/图 */ + scroll_free(); + mr_strip_free(&g_strip); + g_strip_ok = 0; slots_free_all(); mr_book_destroy(&g_book); mr_dir_book_free(&g_db); + if (g_slider) { meow_widget_free(g_slider); g_slider = NULL; } if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } + g_act_mode = g_act_jump = g_act_fit = -1; g_dragging = 0; g_drag_mode = 0; g_drag_off = 0; + g_wo_pull = 0; + g_jump_open = 0; + mr_jump_cancel(&g_jump); g_tap_pending = 0; g_slide_delta = 0; g_slide_from = 0; @@ -1064,3 +1849,12 @@ void mr_reader_open_dir(const char *path) meow_log(MEOW_LOG_INFO, "reader open: %s", g_dir); meow_view_page_push(ACT_READER); } + +#ifdef MR_READER_HOST_TEST +/* 宿主集成测试观察口(不改变任何状态)。 */ +int mr_reader_ui_mode(void) { return g_mode; } +int mr_reader_ui_page(void) { return current_page(); } +int mr_reader_ui_count(void) { return g_count; } +int mr_reader_ui_jump_open(void) { return g_jump_open; } +int mr_reader_ui_jump_pending(void) { return mr_jump_active(&g_jump); } +#endif diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h index 62c7cc3..411981b 100644 --- a/tuku/ui/reader/reader.h +++ b/tuku/ui/reader/reader.h @@ -13,6 +13,15 @@ extern "C" { void reader_on_touch_down(int x, int y); void reader_on_touch_move(int x, int y); +#ifdef MR_READER_HOST_TEST +/* 宿主集成测试专用:只读观察本页状态(不参与产品逻辑)。 */ +int mr_reader_ui_mode(void); /* MR_MODE_PAGED / MR_MODE_WEBTOON */ +int mr_reader_ui_page(void); /* 当前页(条漫=视口顶端所在页) */ +int mr_reader_ui_count(void); /* 当前册页数 */ +int mr_reader_ui_jump_open(void); /* 1=跳页滑块已展开 */ +int mr_reader_ui_jump_pending(void);/* 1=跳页目标等待解码 */ +#endif + #ifdef __cplusplus } #endif