From 23c9db73df840dd4e4d97fa8cc37a1f9f6617bd0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 20:44:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(tuku):=20=E9=98=85=E8=AF=BB=E5=99=A8?= =?UTF-8?q?=E5=B7=A6=E5=8F=B3=E7=BF=BB=E9=A1=B5=E6=A0=B8=E5=BF=83=E2=80=94?= =?UTF-8?q?=E2=80=94=E9=A1=B5=E6=BA=90=E5=A5=91=E7=BA=A6/=E9=A2=84?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E7=AA=97=E5=8F=A3/=E8=B7=9F=E6=89=8B?= =?UTF-8?q?=E7=BF=BB=E9=A1=B5/=E6=96=B9=E5=90=91=E6=84=9F=E7=9F=A5=20tap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 页源契约(logic/reader):count/meta/open/close + 目录册(文件夹即书)为第一个实现; meta 由纯 C 图片头探测(PNG IHDR/JPEG SOF/BMP)提供原始尺寸,条漫可先排版再取图, zip 册(M9/M11)只需另写 mr_book_ops,两种消费方式零改动复用 - 翻页引擎:pthread 后台解码窗口=当前±1 页(目录册 downscale 到屏幕长边),翻页只 blit 缓存页图 + meow_anim 150ms 滑动吸附;未命中立即切页显示加载态、不在翻页路径解码; 只 flush 内容区;decode/flip 首帧/翻页完成/进出内存全部 meow_log 打点 - 阅读页 UI:fit 三档(适宽/适高/原始,TopAppBar 动作循环,标题显示页码 x/y)+ 双击以点击点为锚放大(临时缓冲超上限自动降倍率)+ 单指拖拽/滑动步进平移; tap 三分区方向感知(RTL 默认:左=下一页,LTR 反之),中区切 chrome; 左缘返回交给 app.c、屏底热区交给 SDK 回桌面,两者都不拦截 - 跟手:app.c 仅补 2 处极小路由(ACT_READER 的 down/move),reader 自行算位移; 屏底 28px/左缘 24px 不进拖动,竖向主导放弃横滑 - 内存纪律:驻留 ≤3 张 downscale 页图;放大时窗口收缩到当前页;on_leave 停解码线程、 释放全部页图/册索引/TopAppBar,并记录 VmRSS/MemAvailable 前后对比 - 测试/构建:新增 test_reader_core(契约边界/空册/越界/重复 open-close/首末页/头探测/ 方向语义/fit/平移夹取,108 项);run_tests.sh 与 build_tuku.sh 源列表接线; 宿主 46+108 全 PASS,交叉构建静态 MIPS32r2 ELF、-Wall -Wextra 零警告 --- tuku/app.c | 5 + tuku/build_tuku.sh | 2 +- tuku/logic/reader/nav.c | 120 ++++ tuku/logic/reader/nav.h | 70 ++ tuku/logic/reader/page_source.c | 282 ++++++++ tuku/logic/reader/page_source.h | 91 +++ tuku/test/run_tests.sh | 5 + tuku/test/test_reader_core.c | 452 +++++++++++++ tuku/ui/reader/reader.c | 1111 +++++++++++++++++++++++++++++-- tuku/ui/reader/reader.h | 19 + 10 files changed, 2088 insertions(+), 69 deletions(-) create mode 100644 tuku/logic/reader/nav.c create mode 100644 tuku/logic/reader/nav.h create mode 100644 tuku/logic/reader/page_source.c create mode 100644 tuku/logic/reader/page_source.h create mode 100644 tuku/test/test_reader_core.c create mode 100644 tuku/ui/reader/reader.h diff --git a/tuku/app.c b/tuku/app.c index 7f380ca..893a0d5 100644 --- a/tuku/app.c +++ b/tuku/app.c @@ -10,6 +10,7 @@ * 各页 on_draw / SDK 控件下一帧即用新语义色重绘(不缓存颜色)。 */ #include "tuku_internal.h" +#include "ui/reader/reader.h" #include "meow_ui_nav.h" #include @@ -89,14 +90,18 @@ static void app_on_swipe_progress(int x_start, int dx) meow_ui_swipe_hint(x_start + dx); } +/* 跟手拖动路由(M6):ACT_READER 需要原始 down/move 算位移;其余页保持原语义。 + * 屏底热区/左缘的避让由 reader 侧按同一口径判断(reader_on_touch_down)。 */ static void app_on_touch_down(int x, int y) { if (app_active() == ACT_LIBRARY) library_on_touch_down(x, y); + else if (app_active() == ACT_READER) reader_on_touch_down(x, y); } static void app_on_touch_move(int x, int y) { if (app_active() == ACT_LIBRARY) library_on_touch_move(x, y); + else if (app_active() == ACT_READER) reader_on_touch_move(x, y); } int main(void) diff --git a/tuku/build_tuku.sh b/tuku/build_tuku.sh index 43dc07f..bccc665 100755 --- a/tuku/build_tuku.sh +++ b/tuku/build_tuku.sh @@ -12,7 +12,7 @@ SDK="${MEOW_SDK_DIR:-$HOME/Proj/MeowOS-SDK/sdk}" "$CC" -Os -static -Wall -Wextra -I"$SDK" -I. \ app.c ui/library/library.c ui/reader/reader.c ui/settings/settings.c ui/lock/lock.c \ - logic/library.c logic/reader.c \ + logic/library.c logic/reader.c logic/reader/page_source.c logic/reader/nav.c \ "$SDK/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o picbrowser ls -la picbrowser file picbrowser diff --git a/tuku/logic/reader/nav.c b/tuku/logic/reader/nav.c new file mode 100644 index 0000000..fb7d29d --- /dev/null +++ b/tuku/logic/reader/nav.c @@ -0,0 +1,120 @@ +/* logic/reader/nav.c — 阅读导航纯逻辑实现(见 nav.h 的方向语义说明)。 */ +#include "logic/reader/nav.h" + +int mr_nav_tap_zone(int x, int view_w) +{ + if (view_w <= 0) return MR_ZONE_MIDDLE; + if (x < view_w / 3) return MR_ZONE_LEFT; + if (x >= (view_w * 2) / 3) return MR_ZONE_RIGHT; + return MR_ZONE_MIDDLE; +} + +int mr_nav_tap_delta(int rtl, int zone) +{ + if (zone == MR_ZONE_MIDDLE) return 0; + if (rtl) return (zone == MR_ZONE_LEFT) ? 1 : -1; /* RTL:左进右退 */ + return (zone == MR_ZONE_LEFT) ? -1 : 1; /* LTR:右进左退 */ +} + +int mr_nav_swipe_delta(int rtl, int dx) +{ + if (dx == 0) return 0; + if (rtl) return dx > 0 ? 1 : -1; + return dx > 0 ? -1 : 1; +} + +int mr_nav_clamp_page(int page, int count) +{ + if (count <= 0) return -1; + if (page < 0) return 0; + if (page >= count) return count - 1; + return page; +} + +int mr_nav_commit_dist(int view_w) +{ + int t = view_w / 4; + if (t < 60) t = 60; + if (t > 160) t = 160; + return t; +} + +int mr_nav_commit(int dx, int view_w) +{ + int adx = dx < 0 ? -dx : dx; + return adx >= mr_nav_commit_dist(view_w); +} + +int mr_nav_fit_size(int fit, int src_w, int src_h, int vw, int vh, + int *out_w, int *out_h) +{ + if (!out_w || !out_h || src_w <= 0 || src_h <= 0 || vw <= 0 || vh <= 0) return -1; + long long w, h; + if (fit == MR_FIT_ORIGINAL) { + w = src_w; + h = src_h; + } else if (fit == MR_FIT_HEIGHT) { + h = vh; + w = (long long)src_w * vh / src_h; + } else { + w = vw; + h = (long long)src_h * vw / src_w; + } + if (w < 1) w = 1; + if (h < 1) h = 1; + *out_w = (int)w; + *out_h = (int)h; + return 0; +} + +int mr_nav_zoom_size(int w, int h, int zoom_pct, int *out_w, int *out_h) +{ + if (!out_w || !out_h || w <= 0 || h <= 0 || zoom_pct <= 0) return -1; + long long ww = (long long)w * zoom_pct / 100; + long long hh = (long long)h * zoom_pct / 100; + if (ww < 1) ww = 1; + if (hh < 1) hh = 1; + *out_w = (int)ww; + *out_h = (int)hh; + return 0; +} + +void mr_nav_pan_limit(int content_w, int content_h, int vw, int vh, + int *lim_x, int *lim_y) +{ + int lx = content_w - vw; + int ly = content_h - vh; + if (lx < 0) lx = 0; + if (ly < 0) ly = 0; + /* 平移量是「中心偏移」:可左右各移动溢出量的一半,两边才都看得到。 */ + if (lim_x) *lim_x = lx / 2; + if (lim_y) *lim_y = ly / 2; +} + +void mr_nav_pan_clamp(int content_w, int content_h, int vw, int vh, + int *pan_x, int *pan_y) +{ + int lx, ly; + mr_nav_pan_limit(content_w, content_h, vw, vh, &lx, &ly); + if (pan_x) { + if (*pan_x > lx) *pan_x = lx; + if (*pan_x < -lx) *pan_x = -lx; + } + if (pan_y) { + if (*pan_y > ly) *pan_y = ly; + if (*pan_y < -ly) *pan_y = -ly; + } +} + +int mr_nav_slide_from(int rtl, int delta, int view_w) +{ + if (!delta) return 0; + int from = rtl ? -delta : delta; + return from * view_w; +} + +int mr_nav_fit_next(int fit) +{ + if (fit < 0 || fit >= MR_FIT_COUNT) return MR_FIT_WIDTH; + return (fit + 1) % MR_FIT_COUNT; +} diff --git a/tuku/logic/reader/nav.h b/tuku/logic/reader/nav.h new file mode 100644 index 0000000..4d328f3 --- /dev/null +++ b/tuku/logic/reader/nav.h @@ -0,0 +1,70 @@ +/* logic/reader/nav.h — 阅读导航纯逻辑(方向语义 / tap 分区 / fit / 平移 clamp)。 + * + * 纯 C、无 SDK 依赖:reader.c 与宿主测试共用同一实现(logic/reader/nav.c)。 + * + * 方向语义(mr_reader_get_direction,默认 RTL): + * - LTR(左→右读):页码沿屏幕向右递增;屏左 1/3 = 上一页,屏右 1/3 = 下一页; + * 手指向右拖露出左边那页 = 上一页。 + * - RTL(日漫,默认):页码沿屏幕向左递增;屏左 1/3 = **下一页**,屏右 1/3 = 上一页; + * 手指向右拖露出左边那页 = 下一页。 + * 图像本身不做镜像(RTL 只影响翻页语义)。 + */ +#ifndef MR_READER_NAV_H +#define MR_READER_NAV_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- fit 三档 ---- */ +#define MR_FIT_WIDTH 0 /* 适宽:撑满屏宽 */ +#define MR_FIT_HEIGHT 1 /* 适高:撑满屏高 */ +#define MR_FIT_ORIGINAL 2 /* 原始:1:1 像素 */ +#define MR_FIT_COUNT 3 + +/* ---- tap 分区(按屏幕横向三分) ---- */ +#define MR_ZONE_LEFT 0 +#define MR_ZONE_MIDDLE 1 +#define MR_ZONE_RIGHT 2 + +/* 屏宽三分:左/中/右。view_w<=0 返回 MIDDLE。 */ +int mr_nav_tap_zone(int x, int view_w); + +/* tap 分区的页增量:+1=下一页 / -1=上一页 / 0=chrome 显隐(中区)。 */ +int mr_nav_tap_delta(int rtl, int zone); + +/* 中部横向拖动的页增量。dx>0 = 手指向右(露出屏幕左侧那页)。 + * 首/末页不在这里判断——调用方用 mr_nav_clamp_page 落到实际目标页。 */ +int mr_nav_swipe_delta(int rtl, int dx); + +/* 页下标 clamp;count<=0 返回 -1。 */ +int mr_nav_clamp_page(int page, int count); + +/* 翻页提交阈值:视口宽的 1/4,夹在 [60,160] px。 */ +int mr_nav_commit_dist(int view_w); + +/* 松手位移是否达到翻页阈值。 */ +int mr_nav_commit(int dx, int view_w); + +/* fit 目标尺寸(未含 zoom)。0=成功;参数非法 -1。 */ +int mr_nav_fit_size(int fit, int src_w, int src_h, int vw, int vh, int *out_w, int *out_h); + +/* 按百分比放大(zoom_pct=100 原样;200=2×)。0=成功。 */ +int mr_nav_zoom_size(int w, int h, int zoom_pct, int *out_w, int *out_h); + +/* 平移半幅:内容比视口每侧多出的像素(>=0)。内容不溢出该轴 → 0(该轴不可平移)。 */ +void mr_nav_pan_limit(int content_w, int content_h, int vw, int vh, int *lim_x, int *lim_y); + +/* 把平移量夹到 ±半幅。 */ +void mr_nav_pan_clamp(int content_w, int content_h, int vw, int vh, int *pan_x, int *pan_y); + +/* 翻页动画的进场起点(px):下一页在 RTL 从左侧进、LTR 从右侧进。delta=0 返回 0。 */ +int mr_nav_slide_from(int rtl, int delta, int view_w); + +/* fit 档循环:适宽 → 适高 → 原始 → 适宽。 */ +int mr_nav_fit_next(int fit); + +#ifdef __cplusplus +} +#endif +#endif /* MR_READER_NAV_H */ diff --git a/tuku/logic/reader/page_source.c b/tuku/logic/reader/page_source.c new file mode 100644 index 0000000..f33297d --- /dev/null +++ b/tuku/logic/reader/page_source.c @@ -0,0 +1,282 @@ +/* logic/reader/page_source.c — 页源契约实现(纯 C):目录册 + 图片头探测。 + * + * 契约与两种消费方式的说明见 page_source.h。本文件不含 SDK 依赖:解码/释放由 + * UI 层注回回调,因此可在宿主测试里用假回调覆盖「空册/越界/重复 open-close/首末页」。 + */ +#include "logic/reader/page_source.h" + +#include +#include +#include +#include +#include + +/* ============================================================ */ +/* 契约包装 */ +/* ============================================================ */ +int mr_book_valid(const mr_book *b) +{ + return b && b->ops && b->ops->count && b->ops->open; +} + +int mr_book_count(const mr_book *b) +{ + if (!mr_book_valid(b)) return 0; + int n = b->ops->count(b->ctx); + return n > 0 ? n : 0; +} + +int mr_book_meta(const mr_book *b, int index, int *w, int *h) +{ + if (w) *w = 0; + if (h) *h = 0; + if (!mr_book_valid(b) || !b->ops->meta) return 0; + if (index < 0 || index >= mr_book_count(b)) return 0; + return b->ops->meta(b->ctx, index, w, h) ? 1 : 0; +} + +meow_img *mr_book_open(mr_book *b, int index) +{ + if (!mr_book_valid(b)) return NULL; + if (index < 0 || index >= mr_book_count(b)) return NULL; /* 越界:契约边界 */ + return b->ops->open(b->ctx, index); +} + +void mr_book_close(mr_book *b, meow_img *img) +{ + if (!img) return; + if (!mr_book_valid(b) || !b->ops->close) return; + b->ops->close(b->ctx, img); +} + +void mr_book_destroy(mr_book *b) +{ + if (!b) return; + if (b->ops && b->ops->destroy && b->ctx) b->ops->destroy(b->ctx); + b->ops = NULL; + b->ctx = NULL; +} + +/* ============================================================ */ +/* 目录册 */ +/* ============================================================ */ +static int name_cmp_qsort(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); +} + +void mr_dir_book_path(const mr_dir_book *db, int index, char *out, int outsz) +{ + if (!out || outsz <= 0) return; + out[0] = '\0'; + if (!db || index < 0 || index >= db->count || !db->names[index]) return; + size_t dl = strlen(db->dir); + if (dl > 0 && db->dir[dl - 1] == '/') + snprintf(out, (size_t)outsz, "%s%s", db->dir, db->names[index]); + else + snprintf(out, (size_t)outsz, "%s/%s", db->dir, db->names[index]); +} + +void mr_dir_book_free(mr_dir_book *db) +{ + if (!db) return; + for (int i = 0; i < db->count; i++) free(db->names[i]); + free(db->names); + db->names = NULL; + db->count = 0; +} + +int mr_dir_book_init(mr_dir_book *db, const char *dir, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud) +{ + if (!db) return -1; + memset(db, 0, sizeof *db); + if (!dir || !dir[0] || !load || !unload) return -1; + if (maxdim <= 0) maxdim = 720; + if (strlen(dir) >= sizeof db->dir) return -1; + + DIR *d = opendir(dir); + if (!d) return -1; + + snprintf(db->dir, sizeof db->dir, "%s", dir); + db->maxdim = maxdim; + db->load = load; + db->unload = unload; + db->ud = ud; + + int 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 (name[0] == '.') continue; /* 隐藏文件不参与成册 */ + if (!mr_path_is_image(name)) continue; + + char full[MR_PATH_MAX]; + size_t dl = strlen(db->dir); + int n = (dl > 0 && db->dir[dl - 1] == '/') + ? snprintf(full, sizeof full, "%s%s", db->dir, name) + : snprintf(full, sizeof full, "%s/%s", db->dir, name); + if (n <= 0 || n >= (int)sizeof full) continue; + struct stat st; + if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue; /* 跳过符号链接/目录 */ + + if (db->count >= MR_BOOK_MAX_PAGES) break; + if (db->count >= cap) { + int nc = cap ? cap * 2 : 32; + char **nn = (char **)realloc(db->names, (size_t)nc * sizeof *nn); + if (!nn) { mr_dir_book_free(db); closedir(d); return -1; } + db->names = nn; + cap = nc; + } + size_t len = strlen(name); + db->names[db->count] = (char *)malloc(len + 1); + if (!db->names[db->count]) { mr_dir_book_free(db); closedir(d); return -1; } + memcpy(db->names[db->count], name, len + 1); + db->count++; + } + closedir(d); + + if (db->count > 1) + qsort(db->names, (size_t)db->count, sizeof *db->names, name_cmp_qsort); + return db->count; +} + +/* ============================================================ */ +/* 图片头探测(不解码像素) */ +/* ============================================================ */ +static unsigned probe_be32(const unsigned char *p) +{ + return ((unsigned)p[0] << 24) | ((unsigned)p[1] << 16) | ((unsigned)p[2] << 8) | p[3]; +} + +static int probe_png(FILE *f, int *w, int *h) +{ + unsigned char hdr[24]; + if (fread(hdr, 1, sizeof hdr, f) != sizeof hdr) return 0; + static const unsigned char sig[8] = { 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A }; + if (memcmp(hdr, sig, 8) != 0 || memcmp(hdr + 12, "IHDR", 4) != 0) return 0; + unsigned ww = probe_be32(hdr + 16), hh = probe_be32(hdr + 20); + if (ww == 0 || hh == 0) return 0; + *w = (int)ww; + *h = (int)hh; + return 1; +} + +static int probe_jpeg(FILE *f, int *w, int *h) +{ + unsigned char b[2]; + if (fread(b, 1, 2, f) != 2 || b[0] != 0xFF || b[1] != 0xD8) return 0; + for (;;) { + int c = fgetc(f); + if (c == EOF) return 0; + if (c != 0xFF) continue; + int m = fgetc(f); + while (m == 0xFF) m = fgetc(f); /* 填充字节 */ + if (m == EOF) return 0; + if (m == 0xD8 || m == 0x01 || (m >= 0xD0 && m <= 0xD7)) continue; /* 无长度段 */ + if (m == 0xD9 || m == 0xDA) return 0; /* EOI/SOS 前没等到 SOF */ + unsigned char lb[2]; + if (fread(lb, 1, 2, f) != 2) return 0; + int len = ((int)lb[0] << 8) | lb[1]; + if (len < 2) return 0; + int is_sof = (m >= 0xC0 && m <= 0xCF) && m != 0xC4 && m != 0xC8 && m != 0xCC; + if (is_sof) { + unsigned char so[5]; + if (len < 7 || fread(so, 1, 5, f) != 5) return 0; + int hh = ((int)so[1] << 8) | so[2]; + int ww = ((int)so[3] << 8) | so[4]; + if (ww <= 0 || hh <= 0) return 0; + *w = ww; + *h = hh; + return 1; + } + if (fseek(f, len - 2, SEEK_CUR) != 0) return 0; + } +} + +static int probe_bmp(FILE *f, int *w, int *h) +{ + unsigned char hdr[26]; + if (fread(hdr, 1, sizeof hdr, f) != sizeof hdr) return 0; + if (hdr[0] != 'B' || hdr[1] != 'M') return 0; + int ww = (int)((unsigned)hdr[18] | ((unsigned)hdr[19] << 8) | + ((unsigned)hdr[20] << 16) | ((unsigned)hdr[21] << 24)); + int hh = (int)((unsigned)hdr[22] | ((unsigned)hdr[23] << 8) | + ((unsigned)hdr[24] << 16) | ((unsigned)hdr[25] << 24)); + if (hh < 0) hh = -hh; /* 自底向上 BMP */ + if (ww <= 0 || hh <= 0) return 0; + *w = ww; + *h = hh; + return 1; +} + +int mr_probe_size(const char *path, int *w, int *h) +{ + if (w) *w = 0; + if (h) *h = 0; + if (!path || !path[0] || !w || !h) return 0; + FILE *f = fopen(path, "rb"); + if (!f) return 0; + int ok = probe_png(f, w, h); + if (!ok) { rewind(f); ok = probe_jpeg(f, w, h); } + if (!ok) { rewind(f); ok = probe_bmp(f, w, h); } + fclose(f); + if (ok && (*w <= 0 || *h <= 0)) { *w = *h = 0; return 0; } + return ok; +} + +/* ============================================================ */ +/* mr_book_ops 适配器(目录册) */ +/* ============================================================ */ +static int dir_ops_count(void *ctx) +{ + mr_dir_book *db = (mr_dir_book *)ctx; + return db ? db->count : 0; +} + +static int dir_ops_meta(void *ctx, int index, int *w, int *h) +{ + mr_dir_book *db = (mr_dir_book *)ctx; + if (w) *w = 0; + if (h) *h = 0; + if (!db || index < 0 || index >= db->count) return 0; + char path[MR_PATH_MAX]; + mr_dir_book_path(db, index, path, (int)sizeof path); + return mr_probe_size(path, w, h); +} + +static meow_img *dir_ops_open(void *ctx, int index) +{ + mr_dir_book *db = (mr_dir_book *)ctx; + if (!db || index < 0 || index >= db->count) return NULL; + char path[MR_PATH_MAX]; + mr_dir_book_path(db, index, path, (int)sizeof path); + if (!path[0]) return NULL; + return db->load(path, db->ud, db->maxdim); +} + +static void dir_ops_close(void *ctx, meow_img *img) +{ + mr_dir_book *db = (mr_dir_book *)ctx; + if (db && db->unload && img) db->unload(img); +} + +static void dir_ops_destroy(void *ctx) +{ + mr_dir_book *db = (mr_dir_book *)ctx; + if (db) mr_dir_book_free(db); +} + +static const mr_book_ops g_dir_ops = { + dir_ops_count, dir_ops_meta, dir_ops_open, dir_ops_close, dir_ops_destroy, +}; + +void mr_dir_book_bind(mr_book *b, mr_dir_book *db) +{ + if (!b) return; + b->ops = db ? &g_dir_ops : NULL; + b->ctx = db; +} diff --git a/tuku/logic/reader/page_source.h b/tuku/logic/reader/page_source.h new file mode 100644 index 0000000..fe79858 --- /dev/null +++ b/tuku/logic/reader/page_source.h @@ -0,0 +1,91 @@ +/* logic/reader/page_source.h — 页源契约(纯 C,不依赖 meow_ui.h)。 + * + * 一册 = 有序页序列。契约只有四件事: + * count 总页数 + * meta(i) 页的原始像素尺寸(可选;条漫连续滚动先用它排版,未知时返回 0) + * open(i) 取第 i 页:解码 + downscale 后返回 meow_img*(调用方拥有,必须 close) + * close(img) 释放 open 返回的图像 + * + * 两种消费方式共用同一契约,后续实现零改动: + * - 左右翻页(本任务):预解码窗口 = 当前 ±1 页,翻页只 blit 窗口内已备好的图; + * - 条漫连续滚动(M7):先 meta(i) 累加高度排版,再按滚动位置 open(视口±1 页)。 + * zip 册(M9/M11)只需另写一套 mr_book_ops,目录册是第一个实现。 + * + * meow_img 在 logic 层是前向声明的不透明句柄;解码/释放由 UI 层注入回调 + * (logic 不 include SDK,宿主测试用假回调即可覆盖契约边界)。 + */ +#ifndef MR_PAGE_SOURCE_H +#define MR_PAGE_SOURCE_H + +#include "logic/library.h" /* mr_path_is_image / mr_natural_cmp / MR_PATH_MAX / MR_NAME_MAX */ + +#ifdef __cplusplus +extern "C" { +#endif + +struct meow_img; +typedef struct meow_img meow_img; /* SDK 不透明句柄;logic 层只透传 */ + +#define MR_BOOK_MAX_PAGES 4096 /* 单册页数上限(防坏卡/巨目录撑爆内存) */ + +/* 解码回调(UI 层注入):解码 path,长边 >maxdim 时等比 downscale;失败返回 NULL。 + * 必须可在后台线程调用(宿主测试用假实现)。 */ +typedef meow_img *(*mr_book_load_fn)(const char *path, void *ud, int maxdim); +/* 释放 open 返回的图像(UI 层注入 meow_img_free;宿主测试用假实现计数)。 */ +typedef void (*mr_book_unload_fn)(meow_img *img); + +typedef struct mr_book_ops { + int (*count)(void *ctx); + /* 页原始尺寸(未 downscale);1=已知,0=未知。可为 NULL。 */ + int (*meta)(void *ctx, int index, int *w, int *h); + /* 取页:越界/解码失败返回 NULL。返回值的所有权交给调用方。 */ + meow_img *(*open)(void *ctx, int index); + /* 释放 open 返回的图像;须容忍 NULL。 */ + void (*close)(void *ctx, meow_img *img); + /* 释放 provider 自身(册内索引等)。可为 NULL。 */ + void (*destroy)(void *ctx); +} mr_book_ops; + +typedef struct mr_book { + const mr_book_ops *ops; + void *ctx; +} mr_book; + +/* ---- 契约包装:所有入口对 NULL/越界安全(越界 open 返回 NULL) ---- */ +int mr_book_valid(const mr_book *b); +int mr_book_count(const mr_book *b); +int mr_book_meta(const mr_book *b, int index, int *w, int *h); +meow_img *mr_book_open(mr_book *b, int index); +void mr_book_close(mr_book *b, meow_img *img); +/* 释放 provider 自身(不含已 open 的图像——那些由调用方先 close)。幂等。 */ +void mr_book_destroy(mr_book *b); + +/* ---- 目录册(文件夹即书):目录直接子文件里的图片按自然序成册 ---- */ +typedef struct mr_dir_book { + char dir[MR_PATH_MAX]; + char **names; /* 自然序文件名(堆分配) */ + int count; + int maxdim; /* open 时传给解码回调的长边上限 */ + mr_book_load_fn load; + mr_book_unload_fn unload; + void *ud; /* 解码回调的宿主上下文 */ +} mr_dir_book; + +/* 扫描 dir 的直接子文件,收图片名并自然排序。 + * 返回页数(>=0);参数非法 / 目录不可读 / 内存不足返回 -1(db 已清空、可安全 free)。 */ +int mr_dir_book_init(mr_dir_book *db, const char *dir, int maxdim, + mr_book_load_fn load, mr_book_unload_fn unload, void *ud); +void mr_dir_book_free(mr_dir_book *db); +/* 第 index 页的完整路径;越界时输出空串。 */ +void mr_dir_book_path(const mr_dir_book *db, int index, char *out, int outsz); +/* 把目录册绑定到通用 mr_book(db 生命周期由调用方持有)。 */ +void mr_dir_book_bind(mr_book *b, mr_dir_book *db); + +/* 纯 C 头解析:取图片原始像素尺寸(不解码像素)。 + * PNG(IHDR)/ JPEG(SOF0-15)/ BMP(BITMAPINFOHEADER+)。1=已知,0=未知。 */ +int mr_probe_size(const char *path, int *w, int *h); + +#ifdef __cplusplus +} +#endif +#endif /* MR_PAGE_SOURCE_H */ diff --git a/tuku/test/run_tests.sh b/tuku/test/run_tests.sh index 26a31da..917ff29 100755 --- a/tuku/test/run_tests.sh +++ b/tuku/test/run_tests.sh @@ -6,3 +6,8 @@ gcc -Wall -Wextra -O1 -I. \ test/test_library.c logic/library.c logic/reader.c \ -o test/test_library ./test/test_library +gcc -Wall -Wextra -O1 -I. \ + test/test_reader_core.c logic/library.c logic/reader.c \ + logic/reader/page_source.c logic/reader/nav.c \ + -o test/test_reader_core +./test/test_reader_core diff --git a/tuku/test/test_reader_core.c b/tuku/test/test_reader_core.c new file mode 100644 index 0000000..6edaa58 --- /dev/null +++ b/tuku/test/test_reader_core.c @@ -0,0 +1,452 @@ +/* test_reader_core.c — 阅读器纯逻辑宿主测试(无 SDK/硬件)。 + * + * 覆盖: + * R1 页源契约:空册 / 不可读目录 / 越界 open / 重复 open-close / 首末页 / 自然序 / + * 隐藏文件与子目录排除 / 销毁后失效; + * R2 契约对两种消费方式通用:meta() 给条漫连续滚动排高度,open() 给左右翻页取图; + * R3 图片头探测:PNG/JPEG/BMP 原始尺寸(不解码); + * R4 方向语义:tap 分区 / 拖动增量(RTL 默认与 LTR)/ clamp / 提交阈值; + * R5 fit 三档 / 缩放 / 平移夹取 / 进场方向 / 档位循环; + * R6 阅读方向状态(M3 API)。 + * + * 编译(在 tuku/ 下): + * gcc -Wall -Wextra -O1 -I. test/test_reader_core.c logic/library.c logic/reader.c \ + * logic/reader/page_source.c logic/reader/nav.c -o test/test_reader_core + * 或直接 test/run_tests.sh。 + */ +#include "logic/library.h" +#include "logic/reader.h" +#include "logic/reader/nav.h" +#include "logic/reader/page_source.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) + +/* ============================================================ */ +/* 假解码器:meow_img* 用堆上假句柄代替,记录 open/close 次数 */ +/* ============================================================ */ +typedef struct fake_img { int id; } fake_img; + +static int g_loads, g_frees; +static int g_last_maxdim; +static char g_last_path[MR_PATH_MAX]; + +static meow_img *fake_load(const char *path, void *ud, int maxdim) +{ + (void)ud; + g_loads++; + g_last_maxdim = maxdim; + snprintf(g_last_path, sizeof g_last_path, "%s", path ? path : ""); + fake_img *f = (fake_img *)malloc(sizeof *f); + if (!f) return NULL; + f->id = g_loads; + return (meow_img *)f; +} + +static void fake_unload(meow_img *img) +{ + g_frees++; + free(img); +} + +/* ============================================================ */ +/* 临时目录工具 */ +/* ============================================================ */ +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 write_bytes(const char *path, const unsigned char *buf, int len) +{ + FILE *f = fopen(path, "wb"); + if (!f) return -1; + int ok = fwrite(buf, 1, (size_t)len, f) == (size_t)len; + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int touch_file(const char *path) +{ + return write_bytes(path, (const unsigned char *)"x", 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 int write_png(const char *path, int w, int h) +{ + unsigned char b[24] = { + 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, + 0, 0, 0, 13, 'I', 'H', 'D', 'R', + (unsigned char)(w >> 24), (unsigned char)(w >> 16), + (unsigned char)(w >> 8), (unsigned char)w, + (unsigned char)(h >> 24), (unsigned char)(h >> 16), + (unsigned char)(h >> 8), (unsigned char)h, + }; + return write_bytes(path, b, (int)sizeof b); +} + +static int write_jpeg(const char *path, int w, int h) +{ + unsigned char b[16] = { + 0xFF, 0xD8, /* SOI */ + 0xFF, 0xC0, 0x00, 0x11, /* SOF0, len=17 */ + 0x08, /* precision */ + (unsigned char)(h >> 8), (unsigned char)h, + (unsigned char)(w >> 8), (unsigned char)w, + 0x03, 0x01, 0x11, 0x00, /* 3 分量(长度不足也无妨:只读到 w/h) */ + }; + return write_bytes(path, b, (int)sizeof b); +} + +static int write_bmp(const char *path, int w, int h) +{ + unsigned char b[26] = { 0 }; + b[0] = 'B'; + b[1] = 'M'; + b[18] = (unsigned char)w; b[19] = (unsigned char)(w >> 8); + b[20] = (unsigned char)(w >> 16); b[21] = (unsigned char)(w >> 24); + b[22] = (unsigned char)h; b[23] = (unsigned char)(h >> 8); + b[24] = (unsigned char)(h >> 16); b[25] = (unsigned char)(h >> 24); + return write_bytes(path, b, (int)sizeof b); +} + +/* ============================================================ */ +/* R1/R2 页源契约 */ +/* ============================================================ */ +static void test_contract(void) +{ + char tmpl[] = "./mr_reader_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp"); + if (!root) return; + + char p[MR_PATH_MAX]; + snprintf(p, sizeof p, "%s/page10.png", root); write_png(p, 600, 900); + snprintf(p, sizeof p, "%s/page2.jpg", root); write_jpeg(p, 1200, 1800); + snprintf(p, sizeof p, "%s/page1.bmp", root); write_bmp(p, 800, 1200); + snprintf(p, sizeof p, "%s/notes.txt", root); touch_file(p); + snprintf(p, sizeof p, "%s/.hidden.png", root); touch_file(p); + snprintf(p, sizeof p, "%s/sub.png", root); mkdir_p(p); /* 目录不参与成册 */ + snprintf(p, sizeof p, "%s/sub/inside.png", root); touch_file(p); + + mr_dir_book db; + mr_book book; + int n = mr_dir_book_init(&db, root, 640, fake_load, fake_unload, NULL); + CHECK(n == 3, "init count=%d (want 3)", n); + CHECK(db.count == 3, "db.count=%d", db.count); + mr_dir_book_bind(&book, &db); + + CHECK(mr_book_valid(&book) == 1, "book valid"); + CHECK(mr_book_count(&book) == 3, "book count=3"); + CHECK(mr_book_count(NULL) == 0, "NULL count safe"); + CHECK(mr_book_valid(NULL) == 0, "NULL invalid"); + CHECK(mr_book_open(NULL, 0) == NULL, "NULL open safe"); + mr_book_close(NULL, NULL); /* 全程 NULL 安全,不崩 */ + mr_book_destroy(NULL); + + /* 自然序 + 首末页 */ + char path[MR_PATH_MAX]; + mr_dir_book_path(&db, 0, path, sizeof path); + CHECK(strstr(path, "page1.bmp") != NULL, "page 0 = page1 got %s", path); + mr_dir_book_path(&db, 1, path, sizeof path); + CHECK(strstr(path, "page2.jpg") != NULL, "page 1 = page2 got %s", path); + mr_dir_book_path(&db, 2, path, sizeof path); + CHECK(strstr(path, "page10.png") != NULL, "page 2 = page10 got %s", path); + + /* 越界:open/meta/path 全部安全 */ + CHECK(mr_book_open(&book, -1) == NULL, "open(-1) NULL"); + CHECK(mr_book_open(&book, 3) == NULL, "open(count) NULL"); + CHECK(mr_book_open(&book, 9999) == NULL, "open(9999) NULL"); + int mw = -1, mh = -1; + CHECK(mr_book_meta(&book, -1, &mw, &mh) == 0 && mw == 0 && mh == 0, "meta(-1) unknown"); + CHECK(mr_book_meta(&book, 3, &mw, &mh) == 0, "meta(count) unknown"); + mr_dir_book_path(&db, 9, path, sizeof path); + CHECK(path[0] == '\0', "path out of range empty"); + + /* 首末页 open + 重复 open-close(每次 open 的句柄独立,各 close 一次) */ + int loads0 = g_loads, frees0 = g_frees; + meow_img *a = mr_book_open(&book, 0); + CHECK(a != NULL, "open(0) ok"); + CHECK(strstr(g_last_path, "page1.bmp") != NULL, "open(0) path %s", g_last_path); + CHECK(g_last_maxdim == 640, "open passes maxdim=%d", g_last_maxdim); + meow_img *a2 = mr_book_open(&book, 0); + CHECK(a2 != NULL && a2 != a, "re-open(0) gives independent handle"); + CHECK(g_loads == loads0 + 2, "two loads"); + mr_book_close(&book, a); + mr_book_close(&book, a2); + CHECK(g_frees == frees0 + 2, "frees=%d", g_frees - frees0); + + /* 再开再关一轮:契约允许同一页反复 open/close */ + a = mr_book_open(&book, 0); + CHECK(a != NULL && g_loads == loads0 + 3, "re-open after close"); + mr_book_close(&book, a); + CHECK(g_frees == frees0 + 3, "re-close frees"); + + meow_img *last = mr_book_open(&book, 2); + CHECK(last != NULL, "open(last) ok"); + CHECK(strstr(g_last_path, "page10.png") != NULL, "open(last) path %s", g_last_path); + mr_book_close(&book, last); + + /* meta:PNG/JPEG/BMP 头解析 */ + CHECK(mr_book_meta(&book, 0, &mw, &mh) == 1 && mw == 800 && mh == 1200, + "meta bmp=%dx%d", mw, mh); + CHECK(mr_book_meta(&book, 1, &mw, &mh) == 1 && mw == 1200 && mh == 1800, + "meta jpeg=%dx%d", mw, mh); + CHECK(mr_book_meta(&book, 2, &mw, &mh) == 1 && mw == 600 && mh == 900, + "meta png=%dx%d", mw, mh); + + /* R2:契约对条漫通用——用 meta 累加高度,按滚动位置定位页(M7 可直接复用)。 */ + long y = 0, heights[3]; + for (int i = 0; i < 3; i++) { + int sw = 0, sh = 0; + mr_book_meta(&book, i, &sw, &sh); + int fw = 0, fh = 0; + mr_nav_fit_size(MR_FIT_WIDTH, sw, sh, 480, 720, &fw, &fh); + heights[i] = fh; + y += fh; + } + CHECK(y == 720 + 720 + 720, "column height=%ld", y); + CHECK(heights[0] == 720 && heights[1] == 720, "meta-derived fit heights"); + + /* 销毁:provider 失效,二次 destroy 幂等 */ + mr_book_destroy(&book); + CHECK(mr_book_valid(&book) == 0, "destroyed invalid"); + CHECK(mr_book_open(&book, 0) == NULL, "open after destroy NULL"); + mr_book_destroy(&book); + mr_dir_book_free(&db); + + rm_rf(root); +} + +static void test_empty_and_bad(void) +{ + char tmpl[] = "./mr_reader_empty_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp empty"); + + /* 空目录:可读但 0 页(≠ 错误) */ + mr_dir_book db; + int n = mr_dir_book_init(&db, root, 720, fake_load, fake_unload, NULL); + CHECK(n == 0, "empty dir count=0 got %d", n); + CHECK(db.count == 0, "empty db count"); + mr_book book; + mr_dir_book_bind(&book, &db); + CHECK(mr_book_count(&book) == 0, "empty book count 0"); + CHECK(mr_book_open(&book, 0) == NULL, "empty open NULL"); + CHECK(mr_book_meta(&book, 0, NULL, NULL) == 0, "empty meta NULL-safe"); + mr_dir_book_free(&db); + + /* 目录不存在:-1,且 db 可安全 free */ + mr_dir_book bad; + CHECK(mr_dir_book_init(&bad, "/no/such/dir/tuku-reader", 720, fake_load, fake_unload, + NULL) == -1, "missing dir -> -1"); + CHECK(bad.count == 0 && bad.names == NULL, "missing dir cleared"); + mr_dir_book_free(&bad); + + /* 参数非法 */ + CHECK(mr_dir_book_init(NULL, root, 720, fake_load, fake_unload, NULL) == -1, "NULL db"); + CHECK(mr_dir_book_init(&bad, root, 720, NULL, fake_unload, NULL) == -1, "NULL load"); + CHECK(mr_dir_book_init(&bad, root, 720, fake_load, NULL, NULL) == -1, "NULL unload"); + CHECK(mr_dir_book_init(&bad, "", 720, fake_load, fake_unload, NULL) == -1, "empty dirname"); + + rm_rf(root); +} + +/* ============================================================ */ +/* R4 方向语义 */ +/* ============================================================ */ +static void test_direction_nav(void) +{ + const int W = 480; + CHECK(mr_nav_tap_zone(0, W) == MR_ZONE_LEFT, "zone 0"); + CHECK(mr_nav_tap_zone(159, W) == MR_ZONE_LEFT, "zone 159"); + CHECK(mr_nav_tap_zone(160, W) == MR_ZONE_MIDDLE, "zone 160"); + CHECK(mr_nav_tap_zone(319, W) == MR_ZONE_MIDDLE, "zone 319"); + CHECK(mr_nav_tap_zone(320, W) == MR_ZONE_RIGHT, "zone 320"); + CHECK(mr_nav_tap_zone(479, W) == MR_ZONE_RIGHT, "zone 479"); + CHECK(mr_nav_tap_zone(10, 0) == MR_ZONE_MIDDLE, "zone bad width"); + + /* RTL(默认):屏左=下一页,屏右=上一页 */ + CHECK(mr_nav_tap_delta(1, MR_ZONE_LEFT) == 1, "RTL left=next"); + CHECK(mr_nav_tap_delta(1, MR_ZONE_RIGHT) == -1, "RTL right=prev"); + CHECK(mr_nav_tap_delta(1, MR_ZONE_MIDDLE) == 0, "RTL middle=chrome"); + /* LTR:屏左=上一页,屏右=下一页 */ + CHECK(mr_nav_tap_delta(0, MR_ZONE_LEFT) == -1, "LTR left=prev"); + CHECK(mr_nav_tap_delta(0, MR_ZONE_RIGHT) == 1, "LTR right=next"); + CHECK(mr_nav_tap_delta(0, MR_ZONE_MIDDLE) == 0, "LTR middle=chrome"); + + /* 拖动:向右(dx>0)露出左侧那页 */ + CHECK(mr_nav_swipe_delta(1, 120) == 1, "RTL drag right=next"); + CHECK(mr_nav_swipe_delta(1, -120) == -1, "RTL drag left=prev"); + CHECK(mr_nav_swipe_delta(0, 120) == -1, "LTR drag right=prev"); + CHECK(mr_nav_swipe_delta(0, -120) == 1, "LTR drag left=next"); + CHECK(mr_nav_swipe_delta(1, 0) == 0, "drag 0 no-op"); + + CHECK(mr_nav_clamp_page(-5, 10) == 0, "clamp low"); + CHECK(mr_nav_clamp_page(20, 10) == 9, "clamp high"); + CHECK(mr_nav_clamp_page(3, 10) == 3, "clamp inside"); + CHECK(mr_nav_clamp_page(0, 0) == -1, "clamp empty"); + + CHECK(mr_nav_commit_dist(480) == 120, "commit dist 480 -> 120"); + CHECK(mr_nav_commit_dist(200) == 60, "commit dist 200 -> 60"); + CHECK(mr_nav_commit_dist(2000) == 160, "commit dist 2000 -> 160"); + CHECK(mr_nav_commit(119, 480) == 0, "119 not committed"); + CHECK(mr_nav_commit(120, 480) == 1, "120 committed"); + CHECK(mr_nav_commit(-120, 480) == 1, "-120 committed"); + + /* 进场方向:RTL 下一页从左侧进,LTR 下一页从右侧进 */ + CHECK(mr_nav_slide_from(1, 1, W) == -W, "RTL next enters left"); + CHECK(mr_nav_slide_from(1, -1, W) == W, "RTL prev enters right"); + CHECK(mr_nav_slide_from(0, 1, W) == W, "LTR next enters right"); + CHECK(mr_nav_slide_from(0, -1, W) == -W, "LTR prev enters left"); + CHECK(mr_nav_slide_from(1, 0, W) == 0, "slide 0"); +} + +/* ============================================================ */ +/* R5 fit / 缩放 / 平移 */ +/* ============================================================ */ +static void test_fit_zoom_pan(void) +{ + int w = 0, h = 0; + /* 竖图 1000×1500,视口 480×720 */ + CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 1000, 1500, 480, 720, &w, &h) == 0 && + w == 480 && h == 720, "fit width %dx%d", w, h); + CHECK(mr_nav_fit_size(MR_FIT_HEIGHT, 1000, 1500, 480, 720, &w, &h) == 0 && + w == 480 && h == 720, "fit height %dx%d", w, h); + CHECK(mr_nav_fit_size(MR_FIT_ORIGINAL, 1000, 1500, 480, 720, &w, &h) == 0 && + w == 1000 && h == 1500, "fit original %dx%d", w, h); + + /* 横图 1500×1000:适宽缩到 480×320,适高放大到 1080×720(可左右平移) */ + CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 1500, 1000, 480, 720, &w, &h) == 0 && + w == 480 && h == 320, "landscape fit width %dx%d", w, h); + CHECK(mr_nav_fit_size(MR_FIT_HEIGHT, 1500, 1000, 480, 720, &w, &h) == 0 && + w == 1080 && h == 720, "landscape fit height %dx%d", w, h); + + CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 0, 100, 480, 720, &w, &h) == -1, "fit bad src"); + CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 100, 100, 0, 720, &w, &h) == -1, "fit bad viewport"); + CHECK(mr_nav_fit_size(MR_FIT_WIDTH, 100, 100, 480, 720, NULL, &h) == -1, "fit NULL out"); + + CHECK(mr_nav_zoom_size(480, 720, 200, &w, &h) == 0 && w == 960 && h == 1440, + "zoom 200 %dx%d", w, h); + CHECK(mr_nav_zoom_size(480, 720, 150, &w, &h) == 0 && w == 720 && h == 1080, + "zoom 150 %dx%d", w, h); + CHECK(mr_nav_zoom_size(0, 720, 200, &w, &h) == -1, "zoom bad size"); + + int lx = -1, ly = -1; + mr_nav_pan_limit(960, 1440, 480, 720, &lx, &ly); + CHECK(lx == 240 && ly == 360, "pan limit %d,%d", lx, ly); + mr_nav_pan_limit(480, 720, 480, 720, &lx, &ly); + CHECK(lx == 0 && ly == 0, "no overflow no pan"); + + int px = 9999, py = -9999; + mr_nav_pan_clamp(960, 1440, 480, 720, &px, &py); + CHECK(px == 240 && py == -360, "pan clamp %d,%d", px, py); + px = 10; py = 20; + mr_nav_pan_clamp(480, 720, 480, 720, &px, &py); + CHECK(px == 0 && py == 0, "pan clamp no overflow"); + + CHECK(mr_nav_fit_next(MR_FIT_WIDTH) == MR_FIT_HEIGHT, "fit cycle w->h"); + CHECK(mr_nav_fit_next(MR_FIT_HEIGHT) == MR_FIT_ORIGINAL, "fit cycle h->o"); + CHECK(mr_nav_fit_next(MR_FIT_ORIGINAL) == MR_FIT_WIDTH, "fit cycle o->w"); + CHECK(mr_nav_fit_next(99) == MR_FIT_WIDTH, "fit cycle bad"); +} + +/* ============================================================ */ +/* R3 图片头探测 */ +/* ============================================================ */ +static void test_probe(void) +{ + char tmpl[] = "./mr_reader_probe_XXXXXX"; + char *root = mkdtemp(tmpl); + CHECK(root != NULL, "mkdtemp probe"); + if (!root) return; + + char p[MR_PATH_MAX]; + int w = 0, h = 0; + snprintf(p, sizeof p, "%s/a.png", root); + write_png(p, 1234, 5678); + CHECK(mr_probe_size(p, &w, &h) == 1 && w == 1234 && h == 5678, "probe png %dx%d", w, h); + + snprintf(p, sizeof p, "%s/a.jpg", root); + write_jpeg(p, 1600, 2400); + CHECK(mr_probe_size(p, &w, &h) == 1 && w == 1600 && h == 2400, "probe jpeg %dx%d", w, h); + + snprintf(p, sizeof p, "%s/a.bmp", root); + write_bmp(p, 640, 480); + CHECK(mr_probe_size(p, &w, &h) == 1 && w == 640 && h == 480, "probe bmp %dx%d", w, h); + + snprintf(p, sizeof p, "%s/notes.txt", root); + touch_file(p); + CHECK(mr_probe_size(p, &w, &h) == 0 && w == 0 && h == 0, "probe unknown"); + CHECK(mr_probe_size("/no/such/file.png", &w, &h) == 0, "probe missing"); + CHECK(mr_probe_size(NULL, &w, &h) == 0, "probe NULL"); + + rm_rf(root); +} + +/* ============================================================ */ +/* R6 阅读方向状态 */ +/* ============================================================ */ +static void test_direction_state(void) +{ + mr_reader_set_direction(MR_DIR_RTL); + CHECK(mr_reader_get_direction() == MR_DIR_RTL, "default RTL"); + mr_reader_set_direction(MR_DIR_LTR); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "switch LTR"); + mr_reader_set_direction(42); + CHECK(mr_reader_get_direction() == MR_DIR_LTR, "invalid ignored"); + mr_reader_set_direction(MR_DIR_RTL); +} + +int main(void) +{ + test_contract(); + test_empty_and_bad(); + test_probe(); + test_direction_nav(); + test_fit_zoom_pan(); + test_direction_state(); + 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 33de900..19f9b87 100644 --- a/tuku/ui/reader/reader.c +++ b/tuku/ui/reader/reader.c @@ -1,24 +1,1057 @@ -/* ui/reader/reader.c — 阅读器页(ACT_READER,全屏)M3 骨架。 +/* ui/reader/reader.c — 阅读器页(ACT_READER,全屏):左右翻页模式。 * - * M4 在此实现翻页/图片显示;本批只落可编译桩 + 既有接缝: - * - 书库经 mr_reader_open_dir(path) 导航进来(app.c/library.c 不改); - * - 方向状态走 logic/reader 的 mr_reader_get/set_direction(默认 RTL)。 - * 绘制走 view on_draw(注册表驱动),主题切换由 app.c 的 listener invalidate 全屏重画。 + * 用户口径:优先流畅度,其次功能。翻页路径不解码、不重排: + * - 后台 pthread 解码窗口(当前 ±1 页),主循环只取已备好的图; + * - 跟手拖动只 blit 两张已缓存页图,松手后 meow_anim 一次短滑动吸附(150ms); + * - 只 flush 受影响区(本页=整屏内容区);翻页/解码/驻留耗时都 meow_log 打点。 * - * 接缝注意(M4):meow_view 的 on_create 在 meow_view_create 内运行(此时 activity 栈空), - * 本页是非根页——控件必须 lazy 到 on_enter 建,或照 hello demo_init 的模式 - * meow_ui_activity_push(ACT_READER) → 建控件 → meow_ui_activity_pop() - * (examples/hello/ui/demo/demo.c:515-522);否则控件 owner 绑到 ACT_LIBRARY, - * 造成跨页命中/绘制泄漏(§6.1)。 + * 页源:logic/reader/page_source 的 mr_book 契约(count/meta/open/close)。目录册 + * (文件夹即书)是第一个实现;zip 册与条漫只需另写 provider / 消费方式,本页零改动。 + * 方向:一律读 M3 的 mr_reader_get_direction()(默认 RTL),不另立全局。 + * + * 手势(与 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 只记日志)。 + * + * 节拍:app.c 的 on_tick 只转给 library_on_tick(不在本页 scope)。本页用一条常驻 + * meow_anim “ticker”(SDK 主循环每轮 meow_anim_tick)驱动结果收取与双击判定超时, + * 不新增常驻轮询线程,也不改 on_tick 路由。翻页动画本身也走同一节拍。 + * + * 生命周期:on_enter 建状态 + 建 TopAppBar + 预取首页;on_leave 全量释放(停解码线程、 + * 释放全部页图/册索引/控件)。控件在 on_enter 建 → owner=ACT_READER(非根页防泄漏)。 */ #include "tuku_internal.h" +#include "ui/reader/reader.h" +#include "logic/reader/page_source.h" +#include "logic/reader/nav.h" +#include #include +#include #include +#define MR_WIN 3 /* 预解码窗口:当前 ±1 页 */ +#define MR_TOPBAR_H 56 +#define MR_SLIDE_MS 150 /* 松手后一次短滑动吸附 */ +#define MR_BOUNCE_MS 120 /* 未过阈值回弹 */ +#define MR_DOUBLE_MS 240 /* 单击等待窗口(区分双击) */ +#define MR_ZOOM_PCT 200 /* 双击放大倍率 */ +#define MR_TICK_MS 16 +#define MR_PAINT_MS 16 /* 跟手拖动重绘节流(≈60fps 上限) */ +#define MR_DRAG_SLOP 8 /* 小于此位移算点击 */ +#define MR_ZOOM_TEMP_CAP (6 * 1024 * 1024) /* 缩放临时缓冲软上限(>此值降倍率,保内存) */ + +#define MR_REQ_MAX 8 +#define MR_RES_MAX 4 + +/* view on_event 的事件号不在公共头(sdk/meow_ui_internal.h §6.4);本页只用到 SWIPE=3。 */ +#define MR_EV_SWIPE 3 + static mr_page_ctrl g_ctrl; static char g_dir[MR_PATH_MAX]; +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_vw, g_vh; /* 内容视口 = 全屏 */ +static int g_fit = MR_FIT_WIDTH; +static int g_zoom_pct = 100; +static int g_pan_x, g_pan_y; +static int g_chrome = 1; +static meow_widget *g_tb; + +/* ---- 页图缓存(≤3 张 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]; + +/* ---- 后台解码队列 ---- */ +typedef struct page_req { int idx; unsigned gen; } page_req; +typedef struct page_res { int idx; unsigned gen; meow_img *img; } page_res; + +static pthread_t g_worker; +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER; +static int g_worker_on, g_stop; +static unsigned g_gen = 1; + +static page_req g_req[MR_REQ_MAX]; +static int g_req_head, g_req_tail, g_req_n; +static page_res g_res[MR_RES_MAX]; +static int g_res_n; + +/* ---- 节拍/动画 ---- */ +static meow_anim *g_ticker; +static meow_anim *g_snap; /* 翻页滑动吸附 */ +static meow_anim *g_bounce; /* 未过阈值回弹 */ +static int g_slide_delta; /* 翻页目标增量(动画中) */ +static int g_slide_from; /* 进场页起点(px) */ +static int g_anim_v; /* 进场页当前位移(px) */ +static uint64_t g_flip_t0; +static uint64_t g_last_tick; +static uint64_t g_last_paint; +static int g_quitting; /* on_leave 期间抑制回调 */ + +/* ---- 跟手拖动 ---- */ +static int g_dragging; /* 1=本次按下正在拖动 */ +static int g_drag_mode; /* 0=无 1=横滑翻页 2=放大平移 */ +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_tap_pending; +static int g_tap_x, g_tap_y; +static uint64_t g_tap_ms; +static uint64_t g_last_tap_ms; /* 上一次点击(跨按下保持,双击判定用) */ +static int g_last_tap_x, g_last_tap_y; + +/* ============================================================ */ +/* 页源解码回调(注入 logic 层的 mr_book 契约;可在后台线程调用) */ +/* ============================================================ */ +static meow_img *reader_load_page(const char *path, void *ud, int maxdim) +{ + (void)ud; + meow_img *im = meow_img_load_file(path); + if (!im) return NULL; + meow_img *small = meow_img_downscale(im, maxdim); /* 大图先缩再驻留 */ + if (small) { meow_img_free(im); im = small; } + return im; +} + +static void reader_unload_page(meow_img *img) +{ + meow_img_free(img); +} + +/* ============================================================ */ +/* 内存观测(验收用:进出 reader 对比) */ +/* ============================================================ */ +static long mem_kb(const char *path, const char *key) +{ + FILE *f = fopen(path, "r"); + if (!f) return -1; + char line[128]; + long v = -1; + size_t kl = strlen(key); + while (fgets(line, sizeof line, f)) { + if (strncmp(line, key, kl) == 0) { v = strtol(line + kl, NULL, 10); break; } + } + fclose(f); + return v; +} + +static int resident_pages(void) +{ + int n = 0; + for (int i = 0; i < MR_WIN; i++) + if (g_slot[i].img) n++; + return n; +} + +static long resident_bytes(void) +{ + long b = 0; + for (int i = 0; i < MR_WIN; i++) + if (g_slot[i].img) b += (long)g_slot[i].src_w * g_slot[i].src_h * 4; + return b; +} + +static void log_mem(const char *tag) +{ + meow_log(MEOW_LOG_INFO, "reader mem[%s]: avail=%ldKB rss=%ldKB pages=%d img=%ldKB", + tag, mem_kb("/proc/meminfo", "MemAvailable:"), + mem_kb("/proc/self/status", "VmRSS:"), resident_pages(), resident_bytes() / 1024); +} + +/* ============================================================ */ +/* 页图缓存 */ +/* ============================================================ */ +static page_slot *slot_find(int idx) +{ + if (idx < 0) return NULL; + for (int i = 0; i < MR_WIN; 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 (g_slot[i].img) meow_img_free(g_slot[i].img); + g_slot[i].img = NULL; + g_slot[i].idx = -1; + g_slot[i].src_w = g_slot[i].src_h = 0; +} + +static void slots_free_all(void) +{ + for (int i = 0; i < MR_WIN; i++) slot_clear(i); +} + +/* 放进缓存;缓存满时淘汰离当前页最远的一张。返回 1=已放入。 */ +static int slot_put(int idx, meow_img *img) +{ + if (!img || idx < 0) return 0; + int empty = -1, far = -1, far_d = -1; + for (int i = 0; i < MR_WIN; 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); + if (d > far_d) { far_d = d; far = i; } + } + int i = empty >= 0 ? empty : far; + if (i < 0) { meow_img_free(img); return 0; } + slot_clear(i); + g_slot[i].idx = idx; + g_slot[i].img = img; + g_slot[i].src_w = meow_img_w(img); + g_slot[i].src_h = meow_img_h(img); + return 1; +} + +/* 当前窗口:正常=当前±1;放大时只驻留当前页(给缩放临时缓冲让内存)。 */ +static int in_window(int idx) +{ + 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; +} + +/* ============================================================ */ +/* 后台解码 */ +/* ============================================================ */ +static int req_queued(int idx) +{ + for (int i = 0; i < g_req_n; i++) { + int p = (g_req_head + i) % MR_REQ_MAX; + if (g_req[p].idx == idx) return 1; + } + return 0; +} + +static void queue_page(int idx) +{ + if (!mr_book_valid(&g_book) || idx < 0 || idx >= g_count) return; + if (slot_find(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; + g_req[g_req_tail].gen = g_gen; + g_req_tail = (g_req_tail + 1) % MR_REQ_MAX; + g_req_n++; + pthread_cond_signal(&g_cv); + } + pthread_mutex_unlock(&g_lock); +} + +static void *reader_worker(void *arg) +{ + (void)arg; + for (;;) { + page_req req; + pthread_mutex_lock(&g_lock); + while (!g_stop && g_req_n == 0) pthread_cond_wait(&g_cv, &g_lock); + if (g_stop) { pthread_mutex_unlock(&g_lock); break; } + req = g_req[g_req_head]; + g_req_head = (g_req_head + 1) % MR_REQ_MAX; + g_req_n--; + pthread_mutex_unlock(&g_lock); + + uint64_t t0 = meow_ui_time_ms(); + meow_img *img = mr_book_open(&g_book, req.idx); + uint64_t dt = meow_ui_time_ms() - t0; + int w = img ? meow_img_w(img) : 0; + int h = img ? meow_img_h(img) : 0; + meow_log(MEOW_LOG_INFO, "reader decode page=%d ms=%llu %dx%d %s", + 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) { + 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_n++; + img = NULL; + } + pthread_mutex_unlock(&g_lock); + if (img) meow_img_free(img); + } + return NULL; +} + +static void worker_start(void) +{ + if (g_worker_on) return; + g_stop = 0; + if (pthread_create(&g_worker, NULL, reader_worker, NULL) == 0) g_worker_on = 1; +} + +static void worker_stop(void) +{ + if (!g_worker_on) return; + pthread_mutex_lock(&g_lock); + g_stop = 1; + pthread_cond_broadcast(&g_cv); + pthread_mutex_unlock(&g_lock); + pthread_join(g_worker, NULL); + g_worker_on = 0; + + pthread_mutex_lock(&g_lock); + for (int i = 0; i < g_res_n; i++) meow_img_free(g_res[i].img); + g_res_n = 0; + g_req_n = 0; + g_req_head = g_req_tail = 0; + pthread_mutex_unlock(&g_lock); +} + +/* 丢弃窗口外的排队请求(主线程调用)。 */ +static void queue_prune(void) +{ + pthread_mutex_lock(&g_lock); + page_req keep[MR_REQ_MAX]; + int n = 0; + for (int i = 0; i < g_req_n; i++) { + page_req r = g_req[(g_req_head + i) % MR_REQ_MAX]; + if (r.gen == g_gen && in_window(r.idx)) keep[n++] = r; + } + for (int i = 0; i < n; i++) g_req[i] = keep[i]; + g_req_head = 0; + g_req_tail = n; + g_req_n = n; + pthread_mutex_unlock(&g_lock); +} + +/* 收取后台结果:只留窗口内新页。返回 1=有图落位。 */ +static int results_drain(void) +{ + page_res got[MR_RES_MAX]; + int n; + pthread_mutex_lock(&g_lock); + n = g_res_n; + for (int i = 0; i < n; i++) got[i] = g_res[i]; + g_res_n = 0; + pthread_mutex_unlock(&g_lock); + + int changed = 0; + for (int i = 0; i < n; i++) { + if (got[i].gen == g_gen && 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); + } + } + return changed; +} + +/* 窗口随当前页移动:淘汰窗口外图 → 清掉窗口外请求 → 补齐缺失页(当前页优先)。 */ +static void window_update(void) +{ + for (int i = 0; i < MR_WIN; i++) + if (g_slot[i].idx >= 0 && !in_window(g_slot[i].idx)) slot_clear(i); + queue_prune(); + + 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 void page_target_size(const page_slot *s, int *w, int *h) +{ + *w = *h = 0; + if (!s) return; + if (mr_nav_fit_size(g_fit, s->src_w, s->src_h, g_vw, g_vh, w, h) != 0) return; + if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h); +} + +static void current_geom(int *w, int *h) +{ + page_slot *s = slot_find(g_page); + if (s) { page_target_size(s, w, h); return; } + /* 页未就绪:用 meta 估算,保证平移/缩放的夹取稳定。 */ + int sw = 0, sh = 0; + if (mr_book_meta(&g_book, g_page, &sw, &sh) && sw > 0 && sh > 0) { + mr_nav_fit_size(g_fit, sw, sh, g_vw, g_vh, w, h); + if (g_zoom_pct != 100) mr_nav_zoom_size(*w, *h, g_zoom_pct, w, h); + return; + } + *w = g_vw; + *h = g_vh; +} + +static void clamp_pan(void) +{ + int w, h; + current_geom(&w, &h); + mr_nav_pan_clamp(w, h, g_vw, g_vh, &g_pan_x, &g_pan_y); +} + +/* 内容是否超出视口(原始档的方图/宽图即使不放大也需要平移)。 */ +static int content_overflow(void) +{ + int w, h; + current_geom(&w, &h); + return w > g_vw || h > g_vh; +} + +/* 平移模式:放大,或原始档溢出。此时单指拖动=平移,左右 tap=平移步进。 */ +static int pan_mode(void) +{ + return g_zoom_pct > 100 || content_overflow(); +} + +/* 画一页(带水平位移);图与目标尺寸相等时走原生 blit(翻页热路径),否则最近邻缩放。 */ +static void draw_page_at(int idx, int off_x, int off_y) +{ + page_slot *s = slot_find(idx); + if (!s) return; + int w, h; + page_target_size(s, &w, &h); + if (w <= 0 || h <= 0) return; + int x = (g_vw - w) / 2 + g_pan_x + off_x; + int y = (g_vh - h) / 2 + g_pan_y + off_y; + if (w == s->src_w && h == s->src_h) meow_ui_img(x, y, s->img); + else meow_ui_img_scaled(x, y, w, h, s->img); +} + +static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) +{ + (void)v; (void)ctx; + int w = g_vw, h = g_vh; + + if (g_open_err) { + meow_ui_text_center(w / 2, h / 2 - 20, "无法打开目录", th->font_title, th->onSurface); + meow_ui_text_center(w / 2, h / 2 + 24, g_dir, th->font_body, th->onSurfaceVariant); + return; + } + if (g_count <= 0) { + meow_ui_text_center(w / 2, h / 2 - 20, "空册", th->font_title, th->onSurface); + meow_ui_text_center(w / 2, h / 2 + 24, "这个文件夹里没有图片", th->font_body, + th->onSurfaceVariant); + 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); + return; + } + + if (g_drag_mode == 1 && g_drag_off != 0) { + /* 跟手:当前页按位移走,相邻页在另一侧同时露出(未就绪就只画当前页)。 */ + int rtl = mr_reader_get_direction() == MR_DIR_RTL; + int delta = mr_nav_swipe_delta(rtl, g_drag_off); + int target = mr_nav_clamp_page(g_page + delta, g_count); + if (target != g_page && slot_find(target)) { + int from = mr_nav_slide_from(rtl, delta, g_vw); + draw_page_at(target, g_drag_off + from, 0); + } + draw_page_at(g_page, g_drag_off, 0); + 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); + } +} + +/* ============================================================ */ +/* 状态动作 */ +/* ============================================================ */ +static void update_title(void) +{ + if (!g_tb) return; + 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"); + meow_topappbar_set_title(g_tb, buf); +} + +static void set_fit(int fit) +{ + g_fit = (fit < 0 || fit >= MR_FIT_COUNT) ? MR_FIT_WIDTH : fit; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + update_title(); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +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); +} + +/* 平移(dx>0 = 内容右移);放大或原始档溢出时可用。 */ +static void pan_by(int dx, int dy) +{ + if (!pan_mode()) return; + g_pan_x += dx; + g_pan_y += dy; + clamp_pan(); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void commit_page(int target) +{ + target = mr_nav_clamp_page(target, g_count); + if (target < 0) return; + g_page = target; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + window_update(); + update_title(); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +/* ---- 翻页滑动吸附 ---- */ +static void slide_update(meow_anim *a, float value, void *ctx) +{ + (void)a; (void)ctx; + g_anim_v = (int)value; + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void slide_done(meow_anim *a, int canceled, void *ctx) +{ + (void)a; (void)ctx; + g_snap = NULL; + if (g_quitting) return; + int delta = g_slide_delta; + g_slide_delta = 0; + g_slide_from = 0; + g_anim_v = 0; + if (!canceled && delta) + commit_page(g_page + delta); + else + meow_ui_invalidate(0, 0, g_vw, g_vh); + meow_log(MEOW_LOG_INFO, "reader flip done page=%d/%d total_ms=%llu", + g_page + 1, g_count, + (unsigned long long)(meow_ui_time_ms() - g_flip_t0)); +} + +/* 从 start_off(松手时的位移,无跟手时为 0)滑到目标页。1=动画已启动。 */ +static int start_slide(int delta, int start_off) +{ + if (g_snap || g_bounce) return 0; + int rtl = mr_reader_get_direction() == MR_DIR_RTL; + g_slide_delta = delta; + g_slide_from = mr_nav_slide_from(rtl, delta, g_vw); + g_anim_v = start_off + g_slide_from; + meow_anim_desc d = { + (float)g_anim_v, 0.0f, MR_SLIDE_MS, MEOW_EASE_OUT, + slide_update, slide_done, NULL, + }; + uint64_t t0 = meow_ui_time_ms(); + g_snap = meow_anim_start(&d); /* start 即回调 update:首帧在这里绘制 */ + uint64_t dt = meow_ui_time_ms() - t0; + if (!g_snap) { + g_slide_delta = 0; + g_slide_from = 0; + g_anim_v = 0; + commit_page(g_page + delta); + return 0; + } + meow_log(MEOW_LOG_INFO, "reader slide first_frame_ms=%llu page=%d->%d", + (unsigned long long)dt, g_page + 1, g_page + delta + 1); + return 1; +} + +/* ---- 未过阈值回弹 ---- */ +static void bounce_update(meow_anim *a, float value, void *ctx) +{ + (void)a; (void)ctx; + g_drag_off = (int)value; + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void bounce_done(meow_anim *a, int canceled, void *ctx) +{ + (void)a; (void)canceled; (void)ctx; + g_bounce = NULL; + g_drag_off = 0; + g_drag_mode = 0; + if (!g_quitting) meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +static void start_bounce(int from_off) +{ + if (g_bounce || g_snap) { g_drag_off = 0; g_drag_mode = 0; return; } + meow_anim_desc d = { + (float)from_off, 0.0f, MR_BOUNCE_MS, MEOW_EASE_OUT, + bounce_update, bounce_done, NULL, + }; + g_bounce = meow_anim_start(&d); + 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 (g_snap || g_bounce) return; /* 短动画中忽略连点 */ + if (g_zoom_pct != 100) { /* 翻页回到基准缩放 */ + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + } + results_drain(); + + g_flip_t0 = meow_ui_time_ms(); + if (slot_find(target)) { + if (start_slide(delta, 0)) + meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit", g_page + 1, target + 1); + else + meow_log(MEOW_LOG_INFO, "reader flip %d->%d hit (no anim slot)", g_page + 1, target + 1); + } else { + meow_log(MEOW_LOG_INFO, "reader flip %d->%d miss (decode pending)", g_page + 1, target + 1); + commit_page(target); + } +} + +/* ============================================================ */ +/* 跟手拖动 */ +/* ============================================================ */ +static void drag_paint(void) +{ + uint64_t now = meow_ui_time_ms(); + int gap = g_zoom_pct > 100 ? 33 : MR_PAINT_MS; /* 缩放帧要重采样:30fps 上限 */ + if (now - g_last_paint < (uint64_t)gap) return; + g_last_paint = now; + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +/* 松手:过阈值且目标页已缓存 → 从当前位移吸附过去;否则回弹。 */ +static void drag_finish(void) +{ + if (!g_dragging) return; + g_dragging = 0; + int dx = g_drag_off; + int mode = g_drag_mode; + g_drag_off = 0; + g_drag_mode = 0; + + if (mode != 1 || abs(dx) < MR_DRAG_SLOP) { + meow_ui_invalidate(0, 0, g_vw, g_vh); + return; + } + 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)) { + 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); + } else { + meow_log(MEOW_LOG_INFO, "reader drag flip %d->%d miss (decode pending)", + g_page + 1, target + 1); + commit_page(target); + } + } else { + g_drag_off = dx; /* start_bounce 从这里起 */ + g_drag_mode = 1; + start_bounce(dx); + } +} + +void reader_on_touch_down(int x, int y) +{ + if (!g_ctrl.active || g_quitting) return; + if (g_count <= 0) return; + if (y >= g_vh - 28) return; /* 屏底热区:系统回桌面,勿劫持 */ + if (x < 24) return; /* 左缘:系统返回手势,勿劫持 */ + if (g_chrome && y < MR_TOPBAR_H) return; /* chrome 上的点击交给 tap */ + if (g_snap || g_bounce) return; /* 短动画中不接新手势 */ + + g_dragging = 1; + g_drag_mode = pan_mode() ? 2 : 1; + g_drag_x0 = x; + g_drag_y0 = 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; + int dx = x - g_drag_x0; + int dy = y - g_drag_y0; + int adx = abs(dx), ady = abs(dy); + if (!g_touch_moved && (adx >= MR_DRAG_SLOP || ady >= MR_DRAG_SLOP)) { + g_touch_moved = 1; + g_tap_pending = 0; /* 变成拖动:作废未决单击 */ + g_last_tap_ms = 0; + } + + if (g_drag_mode == 2) { /* 放大态:单指拖拽平移 */ + g_pan_x = g_drag_pan_x0 + dx; + g_pan_y = g_drag_pan_y0 + dy; + clamp_pan(); + drag_paint(); + return; + } + + /* 竖向主导 → 放弃横滑翻页(松手后不算点击,见 reader_on_tap)。 */ + if (ady > adx * 3 / 2 && ady > 16) { + g_dragging = 0; + g_drag_mode = 0; + g_drag_off = 0; + meow_ui_invalidate(0, 0, g_vw, g_vh); + return; + } + + 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); + int off = dx; + if (target == g_page) { /* 首/末页:橡皮筋 */ + int lim = g_vw / 4; + if (off > lim) off = lim; + if (off < -lim) off = -lim; + } + g_drag_off = off; + drag_paint(); +} + +/* ============================================================ */ +/* 单击 / 双击 */ +/* ============================================================ */ +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) { + meow_view_page_pop(); + return; + } + if (hit >= 0) set_fit(mr_nav_fit_next(g_fit)); +} + +static void tap_content(int x, int y) +{ + (void)y; + if (g_count <= 0) return; + int zone = mr_nav_tap_zone(x, g_vw); + if (zone == MR_ZONE_MIDDLE) { + set_chrome(!g_chrome); + return; + } + if (pan_mode()) { + /* 平移模式:左右分区改为平移步进(配合拖拽,补齐两个方向)。 */ + pan_by(zone == MR_ZONE_LEFT ? g_vw / 3 : -g_vw / 3, 0); + return; + } + page_flip(mr_nav_tap_delta(mr_reader_get_direction() == MR_DIR_RTL, zone)); +} + +static void tap_double(int x, int y) +{ + if (g_count <= 0) return; + if (g_zoom_pct > 100) { + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + } else { + /* 以双击点为锚放大:该点保持在手指下(再夹到可平移范围)。 + * 倍率按内存纪律夹取:缩放临时缓冲 = 目标宽×高×4,超过软上限就降档。 */ + int bw = 0, bh = 0; + current_geom(&bw, &bh); + int z = MR_ZOOM_PCT; + while (z > 100 && (long long)bw * bh * 4 * z * z / 10000 > MR_ZOOM_TEMP_CAP) z -= 25; + g_zoom_pct = z; + g_pan_x = (g_vw / 2 - x) * (z - 100) / 100; + g_pan_y = (g_vh / 2 - y) * (z - 100) / 100; + clamp_pan(); + } + window_update(); + meow_ui_invalidate(0, 0, g_vw, g_vh); +} + +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); + else tap_content(x, y); +} + +void reader_on_tap(int x, int y) +{ + if (!g_ctrl.active || g_quitting) return; + + /* 跟手手势收尾:移动过就按拖动处理(过阈值翻页 / 否则回弹),不再当点击。 */ + if (g_dragging) { + if (g_touch_moved) { + drag_finish(); + g_touch_moved = 0; + return; + } + g_dragging = 0; + g_drag_mode = 0; + g_drag_off = 0; + } else if (g_touch_moved) { + g_touch_moved = 0; /* 竖向拖动被 SDK 报成 TAP:忽略 */ + return; + } + g_touch_moved = 0; + + /* chrome 上的点击即时生效,不参与双击判定。 */ + if (g_chrome && y >= 0 && y < MR_TOPBAR_H) { + g_tap_pending = 0; + g_last_tap_ms = 0; + tap_topbar(x, y); + return; + } + + uint64_t now = meow_ui_time_ms(); + if (g_last_tap_ms && now - g_last_tap_ms <= MR_DOUBLE_MS && + abs(x - g_last_tap_x) <= 48 && abs(y - g_last_tap_y) <= 48) { + g_tap_pending = 0; /* 取消第一击的单击动作 */ + g_last_tap_ms = 0; + tap_double(x, y); + return; + } + tap_flush(); /* 上一击已过期:先执行 */ + g_tap_pending = 1; + g_tap_x = x; + g_tap_y = y; + g_tap_ms = now; + g_last_tap_ms = now; + g_last_tap_x = x; + g_last_tap_y = y; + if (!g_ticker) tap_flush(); /* 无节拍器兜底:不延迟单击 */ +} + +/* ============================================================ */ +/* 节拍:收结果 + 单击超时 */ +/* ============================================================ */ +static void reader_tick(void) +{ + if (!g_ctrl.active || g_quitting) return; + uint64_t now = meow_ui_time_ms(); + 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) + meow_ui_invalidate(0, 0, g_vw, g_vh); + + if (g_tap_pending && now - g_tap_ms >= MR_DOUBLE_MS) tap_flush(); +} + +static void ticker_update(meow_anim *a, float value, void *ctx) +{ + (void)a; (void)value; (void)ctx; + reader_tick(); +} + +static void ticker_done(meow_anim *a, int canceled, void *ctx) +{ + (void)a; (void)ctx; + g_ticker = NULL; + if (canceled || g_quitting || !g_ctrl.active) return; + meow_anim_desc d = { 0.0f, 1.0f, 0xFFFFFFFFu, MEOW_EASE_LINEAR, + ticker_update, ticker_done, NULL }; + g_ticker = meow_anim_start(&d); +} + +static void ticker_start(void) +{ + if (g_ticker) return; + meow_anim_desc d = { 0.0f, 1.0f, 0xFFFFFFFFu, MEOW_EASE_LINEAR, + ticker_update, ticker_done, NULL }; + g_ticker = meow_anim_start(&d); + if (!g_ticker) meow_log(MEOW_LOG_WARN, "reader ticker unavailable (anim pool full)"); +} + +/* ============================================================ */ +/* 输入:滑动手势(view on_event,先于 app.c 的 on_swipe) */ +/* ============================================================ */ +static int reader_event(meow_view *v, int ev, int a, int b, void *ctx) +{ + (void)v; (void)ctx; + if (!g_ctrl.active || g_quitting) return 0; + if (ev != MR_EV_SWIPE) return 0; + + if (a == MEOW_SWIPE_LEFT) { /* 左缘返回:交给 app.c page_pop */ + g_dragging = 0; + g_drag_mode = 0; + g_drag_off = 0; + g_touch_moved = 0; + return 0; + } + if (a == MEOW_SWIPE_RIGHT) { + if (g_dragging) { drag_finish(); return 1; } + if (g_count <= 0) return 1; + if (pan_mode()) { pan_by(b, 0); return 1; } + page_flip(mr_nav_swipe_delta(mr_reader_get_direction() == MR_DIR_RTL, b)); + return 1; + } + if (a == MEOW_SWIPE_UP || a == MEOW_SWIPE_DOWN) { + if (g_dragging) { drag_finish(); return 1; } + if (pan_mode()) { /* 放大 / 原始档溢出:纵向滑动平移 */ + pan_by(0, a == MEOW_SWIPE_DOWN ? b : -b); + return 1; + } + return 0; /* 未放大:纵向滑动不归本页 */ + } + return 0; +} + +/* ============================================================ */ +/* view 生命周期 */ +/* ============================================================ */ +static void reader_state_reset(void) +{ + slots_free_all(); /* 静态槽位初值是 idx=0/img=NULL,先清空 */ + g_count = 0; + g_open_err = 0; + g_page = 0; + g_fit = MR_FIT_WIDTH; + g_zoom_pct = 100; + g_pan_x = g_pan_y = 0; + g_chrome = 1; + g_slide_delta = 0; + g_slide_from = 0; + g_anim_v = 0; + g_dragging = 0; + g_drag_mode = 0; + g_drag_off = 0; + g_touch_moved = 0; + g_tap_pending = 0; + g_last_tap_ms = 0; + g_quitting = 0; +} + +static void reader_page_enter(meow_view *v, void *ctx) +{ + mr_page_ctrl *c = (mr_page_ctrl *)ctx; + c->active = 1; + g_quitting = 0; + + g_vw = meow_ui_width(); + g_vh = meow_ui_height(); + v->x = 0; + v->y = 0; + v->w = g_vw; + v->h = g_vh; + + meow_ui_set_fullscreen(1); + const meow_theme *th = meow_theme_get(); + meow_ui_set_page_bg(th->surface); + + reader_state_reset(); + g_gen++; /* 作废旧册的异步结果 */ + g_last_tick = 0; + 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); + } + + /* 页源:目录册(文件夹即书)。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); + + 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 里落位。 */ + reader_tick(); +} + +static void reader_page_leave(meow_view *v, void *ctx) +{ + (void)v; + ((mr_page_ctrl *)ctx)->active = 0; + g_quitting = 1; + + 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(); /* 先停线程,再释放册/图 */ + slots_free_all(); + mr_book_destroy(&g_book); + mr_dir_book_free(&g_db); + + if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; } + + g_dragging = 0; + g_drag_mode = 0; + g_drag_off = 0; + g_tap_pending = 0; + g_slide_delta = 0; + g_slide_from = 0; + g_anim_v = 0; + + log_mem("leave"); + meow_ui_set_fullscreen(0); +} + +meow_view *reader_view_create(void) +{ + static const meow_view_hooks h = { + .on_enter = reader_page_enter, + .on_leave = reader_page_leave, + .on_draw = reader_draw, + .on_event = reader_event, + }; + return meow_view_create(ACT_READER, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); +} + +/* ============================================================ */ +/* 书库 → 阅读器导航(app.c / library.c 既有接缝) */ +/* ============================================================ */ const char *mr_reader_dir(void) { return g_dir; @@ -31,61 +1064,3 @@ void mr_reader_open_dir(const char *path) meow_log(MEOW_LOG_INFO, "reader open: %s", g_dir); meow_view_page_push(ACT_READER); } - -void reader_on_tap(int x, int y) -{ - (void)x; (void)y; - if (!g_ctrl.active) return; - mr_reader_set_direction(mr_reader_get_direction() == MR_DIR_RTL ? MR_DIR_LTR : MR_DIR_RTL); - meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height()); -} - -static void reader_draw(meow_view *v, const meow_theme *th, void *ctx) -{ - (void)v; (void)ctx; - int w = meow_ui_width(); - const char *dir = g_dir[0] ? g_dir : "(no directory)"; - size_t n = strlen(dir); - if (n > 64) dir += n - 64; - - meow_ui_text_center(w / 2, 160, "READER", th->font_title, th->primary); - meow_ui_text_center(w / 2, 200, "(M4 will render pages here)", th->font_body, - th->onSurfaceVariant); - meow_ui_text_center(w / 2, 280, "Folder:", th->font_body, th->onSurfaceVariant); - meow_ui_text_center(w / 2, 308, dir, th->font_body, th->onSurface); - meow_ui_text_center(w / 2, 372, - mr_reader_get_direction() == MR_DIR_RTL ? "Direction: RTL" : "Direction: LTR", - th->font_body, th->onSurface); - meow_ui_text_center(w / 2, 420, "tap = toggle direction", th->font_body, th->onSurfaceVariant); - meow_ui_text_center(w / 2, 448, "left-edge swipe = back", th->font_body, th->onSurfaceVariant); -} - -static void reader_page_enter(meow_view *v, void *ctx) -{ - ((mr_page_ctrl *)ctx)->active = 1; - v->x = 0; - v->y = 0; - v->w = meow_ui_width(); - v->h = meow_ui_height(); - meow_ui_set_fullscreen(1); /* app.json route.reader.fullscreen 的运行时兜底 */ - meow_ui_set_page_bg(meow_theme_get()->bg); - mr_nav_report(); - meow_ui_paint_full(); -} - -static void reader_page_leave(meow_view *v, void *ctx) -{ - (void)v; - ((mr_page_ctrl *)ctx)->active = 0; - meow_ui_set_fullscreen(0); -} - -meow_view *reader_view_create(void) -{ - static const meow_view_hooks h = { - .on_enter = reader_page_enter, - .on_leave = reader_page_leave, - .on_draw = reader_draw, - }; - return meow_view_create(ACT_READER, MEOW_VIEW_ACTIVITY, &h, &g_ctrl); -} diff --git a/tuku/ui/reader/reader.h b/tuku/ui/reader/reader.h new file mode 100644 index 0000000..62c7cc3 --- /dev/null +++ b/tuku/ui/reader/reader.h @@ -0,0 +1,19 @@ +/* ui/reader/reader.h — 阅读页给 app.c 的事件接缝(跟手拖动/平移)。 + * + * app.c 只做最小路由:ACT_READER 时的 on_touch_down/move 转给这里(镜像 library 分支)。 + * 其余入口(on_tap / view on_event / mr_reader_open_dir)仍走 tuku_internal.h 的既有声明。 + */ +#ifndef TUKU_UI_READER_READER_H +#define TUKU_UI_READER_READER_H + +#ifdef __cplusplus +extern "C" { +#endif + +void reader_on_touch_down(int x, int y); +void reader_on_touch_move(int x, int y); + +#ifdef __cplusplus +} +#endif +#endif /* TUKU_UI_READER_READER_H */