feat(tuku): 阅读器左右翻页核心——页源契约/预解码窗口/跟手翻页/方向感知 tap

- 页源契约(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 零警告
This commit is contained in:
AskaEth committed 2026-11-04 20:58:05 +08:00
1 parent 28b67a7b8d
commit 23c9db73df
10 files changed
+2088 -69

No files matched your search

+5
View File
@@ -10,6 +10,7 @@
* 各页 on_draw / SDK 控件下一帧即用新语义色重绘(不缓存颜色)。
*/
#include "tuku_internal.h"
#include "ui/reader/reader.h"
#include "meow_ui_nav.h"
#include <signal.h>
@@ -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)
+1 -1
View File
@@ -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
+120
View File
@@ -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;
}
+70
View File
@@ -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 */
+282
View File
@@ -0,0 +1,282 @@
/* logic/reader/page_source.c — 页源契约实现(纯 C):目录册 + 图片头探测。
*
* 契约与两种消费方式的说明见 page_source.h。本文件不含 SDK 依赖:解码/释放由
* UI 层注回回调,因此可在宿主测试里用假回调覆盖「空册/越界/重复 open-close/首末页」。
*/
#include "logic/reader/page_source.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
/* ============================================================ */
/* 契约包装 */
/* ============================================================ */
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;
}
+91
View File
@@ -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 */
+5
View File
@@ -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
+452
View File
@@ -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 <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
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;
}
+1043 -68
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -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 */