Files
MeowOS-For-R3P2/patch_v2/reader_app/app/shelf.c
T
AskaEth bd6fc8da3c fix(reader): M41 r1 评审 P2 — 拖动重绘节流 + 终帧补齐 + 三条边界测试
P2-1(用户可感知):shelf_on_touch_move 每收到一条 TOUCH_MOVE 就同步全量重绘。
输入线程「坐标一变就一条 MOVE」且不合并(队列 128 条),folder ≤200 行走圆角卡
≈40ms/帧 → 主循环追不上手指、抬手后还会继续滑、极端情况丢 release。修法:
- 滚动位置仍每条 move 立即生效(纯算术,便宜);上屏节流:首帧立即画,之后每
  max(16ms, 上一帧实测耗时) 一次(自适应窗口,圆角卡 40ms 帧自动降到 ~40ms 周期,
  不再把队列拖垮)。
- 手势收尾(shelf_on_tap / shelf_on_swipe)无条件补终帧再清手势态;下一次按下也会
  先把上一次遗留的未上屏帧补上(HOME/长按等没有 TAP/SWIPE 收尾的手势)。
- 位置没变(已到顶/底)连帧都不画。

P2-2(测试盲区):
- 新增三条边界:顶栏起手不拖、副题区起手不拖、横向手势判向取消后不再滚动。
- 吞 TAP 边界带:20/40/60px(>8px 且 <65px,SDK 报 TAP)抬在目录行上不得进目录;
  6px 抖动(≤8px)仍算点击能进目录。
- 断言从「像素变了」升级为「像素**恰好**位移 N px」(内容区快照逐行比对,避开视口
  边缘裁剪带):swipe 120 → 恰好 120;拖动 60 → 恰好 60;并新增 6 步×10px 多步拖动
  断言终帧恰好 60(专测节流不丢最后一段)。
- 修掉把 20px 写成「< SHELF_DRAG_TAP_PX」的错误注释。

顺手:
- shelf_draw_notice 用 th->bg 而不是只在 shelf_redraw 赋值的 g_bg。
- build_reader.sh 用 trap 保证测试失败时也清 $TMPDIR/m41_scroll_sd。

验证:shelf_scroll_smoke 38/38、shelf_group_smoke 99/99、shelf_index_smoke 170/170。
红绿对照:dist=dy(旧 swipe 语义)→ 9 条 FAIL;拿掉「抬手补终帧」→ 恰好 1 条 FAIL
(6 步拖动终帧那条),证明节流与终帧都有测试盯着。
reader 1719220B md5 890175ec…;mpk 818162B md5 ffbb7097…(0.1.8/code9,未签名)。
2026-11-11 13:15:54 +08:00

883 lines
42 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* app/shelf.c — 书架:扫 TF 卡 `Books/`,识别 TXT/FB2/EPUB/MOBI/CBZ(R1 仅 TXT 可开,
* 其它显示「R2 支持」占位);MD3 布局(meow_topappbar + list/grid)+ 最近阅读置顶 + 段头分组。
*
* 布局(README §3.2,用户 2026-09-04 拍板):
* - 默认 list(meow_listview_set_container(on) MD3 圆角卡;**行数 > SHELF_ROUND_MAX_ROWS
* 时自动降级为扁平行**——圆角卡逐像素填充太慢,见 shelf_build_content 注释),
* 可切 grid(meow_lazygrid 4 列封面卡);布局存 reader.conf `ui.shelf_layout`;
* - 顶栏 meow_topappbar:标题「书架」+ 动作(布局切换 / 分组切换 / 刷新 / App 设置入口);
* - 无书也渲染完整骨架(顶栏 + 内容区 + 居中空态卡 + 指引 + 支持格式);
* - 封面:MVP 无封面提取(engine F1);无则圆角占位(surfaceContainerHigh + 格式徽标)。
*
* M27 分组(`ui.shelf_group`,默认 format 保持旧行为):
* format = 按格式分段(TXT/FB2/EPUB/MOBI/CBZ),与改动前完全一致;
* folder = **目录导航**(M33):书架只显示当前目录(cwd)的内容——子目录在前(带子树书数)、
* 直接属于该层的书在后;点目录进入、点书打开;左缘返回/顶栏返回箭头回上一级,
* 到 Books 根再返回才退出 App。行序列在 app/shelf_model.c(纯 C,宿主可测);
* 本文件只维护 cwd 并做控件装配/绘制。导航只过滤已扫平的 g_books,不重扫/不 readdir。
* M33 状态:folder↔format 切换与 list↔grid 切换都**保留 cwd**(切回 folder 还在原目录);
* 顶栏「刷新」重扫后若 cwd 已不存在(目录/书被删),收缩到最近的仍有书的祖先。
*
* M29 性能(用户反馈「几百本书首页卡得飞起」):
* - **扫描结果进程内缓存**:只在本进程首次进入书架时扫 Books/;从阅读页返回不再重扫
* (真机 exFAT 全量扫描 3.1s,是「回书架冻结」的唯一大头)。顶栏「刷新」强制重扫。
* 行为变化:新放进 Books/ 的书需点一次「刷新」才出现。
* - **行数多时关圆角容器**(见 SHELF_ROUND_MAX_ROWS):圆角卡逐像素填充,595 本时滑一步 46ms。
* M30:M29 的「刚读完不置顶」缺口补上(只刷一本书的 ts + 内存重排,不重扫全盘)。
* M31:置顶时一并重算这本的副题(shelf_fmt_sub),「· 读」立即出现,不等下一次全量刷新;
* 顶栏「刷新」同时丢弃字体路径缓存(新拷入的字库此时生效)。
* M32:书架索引落盘(app/shelf_index.c)。冷启动先试 `MeowData/os.meow.reader/index/shelf.idx`:
* 版本、CRC、root 路径、**Books 根 + 一级目录** 的 mtime 校验通过 → 直接出书,**完全跳过 readdir**。
* 真机冷缓存实测 434 个目录逐个 stat 要 3.15s(与 readdir 同量级),所以只校验
* 根 + 一级目录(4 次 stat ≈20ms);代价是 level-2 及更深处的变更不会自动失效,
* 需点「刷新」(与 M29「新书靠刷新」同一约定)。任何异常 → 照常全量重扫(fail-open)。
* 扫描完成后原子写回索引(写失败只丢缓存,不影响书架)。
* M29 语义不变:首次进书架=索引或全扫;从阅读页返回=不扫;顶栏「刷新」=清索引+全扫。
*
* M41 滚动(用户 2026-11-11:「列表和宫格都滑不动」,178 本目录同样):
* 书架此前**从来没有**滚动输入通路——不是回归,是 M6.40 首版就缺:
* ① `app_on_touch_down/move` 只路由给阅读页 → 没有跟手拖动(files/ncm 能滑正是因为
* 它们自己实现了 touch_down/move → meow_listview_scroll_by);
* ② `shelf_on_swipe` 用 dy 去 scroll_by,而 SDK 固定按 `on_swipe(dir, dist, 0)` 分发
* (位移在第 2 参,dy 恒 0)→ 恒 scroll_by(0)。
* 本版补齐:跟手拖动(含横向手势锁定,别抢左缘返回)+ swipe 按 dist 滚(兼容 dy),
* 两者共用 shelf_scroll_by;拖动后的抬手吞掉 TAP(位移 <65px 的拖动 SDK 会报 TAP,
* 不吞就会在滑动时误开书/误进目录)。滚动只重画内容区,顶栏/副题不动。
*/
#include "../reader_internal.h"
#include "font_resolve.h"
#include "progress.h"
#include "reader_conf.h"
#include "shelf_index.h"
#include "shelf_model.h"
#include "meow_ui_internal.h" /* meow_gfx_fill_round(宫格卡圆角填充) */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define SHELF_APPBAR_H 56
#define SHELF_LIST_X 0
#define SHELF_LIST_W 480
#define SHELF_SUB_H 22 /* 顶栏下「副题行」高度:内容视口从 top+SHELF_SUB_H 开始 */
/* 圆角卡容器阈值:行数 ≤ 该值用 MD3 圆角卡(观感优先),超过则扁平行(滚动流畅优先)。 */
#define SHELF_ROUND_MAX_ROWS 200
/* M41 触摸拖动:纵向累计位移达到 START 才锁定为滚动;达到 TAP 后吞掉随后的 TAP
* (SDK 只在位移 ≥65px 时报 SWIPE,小于它的拖动会落成 TAP,不吞就会误进目录/误开书)。 */
#define SHELF_DRAG_START_PX 4
#define SHELF_DRAG_TAP_PX 8
/* 拖动重绘节流:滚动位置每个 move 都立即生效(便宜),上屏最多每 FRAME_MS 一次;
* 输入线程「坐标一变就一条 TOUCH_MOVE」(无合并、队列 128 条),圆角卡一帧 40ms+,
* 不节流会让主循环永远追不上手指、抬手后还继续滑。窗口再取 max(16ms, 上一帧实测耗时)。 */
#define SHELF_DRAG_FRAME_MS 16
static shelf_book g_books[SHELF_MAX_BOOKS];
static int g_nbooks;
static int g_scanned; /* 1=本进程已扫过 Books/(进书架缓存;「刷新」动作强制重扫) */
static int g_active;
static char g_cwd[SHELF_GROUP_MAX]; /* M33 folder 模式当前相对目录;""=Books 根 */
static char g_pending_enter[SHELF_GROUP_MAX]; /* 目录行点击:等 tap_at 返回后再重建列表 */
/* M30:刚打开过的书(返回书架时只刷这一本的 ts 并重排,避免为「最近阅读置顶」重扫全盘)。 */
static char g_just_read[400];
static meow_widget *g_list; /* listview(布局=list) */
static meow_lazygrid *g_grid; /* lazygrid(布局=grid) */
static meow_widget *g_appbar; /* meow_topappbar */
static int g_layout; /* 0=list 1=grid(读 rdconf->shelf) */
static int g_group_folder;/* 0=format 1=folder(读 rdconf->shelf_group) */
/* M41 滚动:SDK 只在 on_swipe 里给总位移(且放在第 2 参,dy 恒 0),跟手拖动走
* on_touch_down/on_touch_move。g_drag_state:0=无手势 1=按下待判向 2=纵向滚动中
* 3=横向手势(左缘返回等,不滚)。 */
static int g_drag_state;
static int g_drag_x0, g_drag_y0; /* 按下原点(判向:纵向 vs 横向) */
static int g_drag_last_y; /* 上一帧 Y(逐帧增量) */
static int g_drag_scrolled; /* 本次手势已实际滚过(抬手不再叠加/不当作点击) */
static int g_drag_dirty; /* 有已生效但未上屏的滚动(抬手补终帧) */
static uint64_t g_drag_draw_ms; /* 上次拖动重绘时刻(0=本手势还没画过 → 首帧立即画) */
static int g_drag_frame_ms; /* 上次拖动重绘耗时(节流窗口自适应) */
static uint32_t g_bg, g_fg, g_dim;
static char g_notice[160]; /* 提示第 1 行(opendir 失败/超上限截断等) */
static char g_notice2[160]; /* 提示第 2 行(超量+超深并存时才用;空=单行) */
static int g_notice_ms; /* 1=有提示(0=无) */
static uint64_t g_notice_until; /* 提示到期时刻(meow_ui_time_ms;6s 后自动消失) */
/* 段头行(list 模式):[段头, 书, 书, ..., 段头, ...];SHELF_MAX_ROWS=2×书上限(见 shelf_model.h)。 */
static shelf_row g_rows[SHELF_MAX_ROWS];
static int g_nrows;
/* 控件装配/顶栏定义在本文件后部;目录导航的进/退要重建它们。 */
static void shelf_appbar_build(void);
static void shelf_build_content(void);
static void shelf_drag_reset(void); /* 控件重建时清滚动手势态(M41) */
static const char *fmt_name(int f) { return shelf_fmt_name(f); }
/* 进度时间戳(分组/排序用;不改 path,故进度键不受分组影响)。 */
static uint64_t shelf_ts_of(const char *path)
{
rdprog p;
return (rdprog_load(path, &p) == 1) ? p.ts : 0;
}
static void notice_set(const char *line1, const char *line2)
{
snprintf(g_notice, sizeof g_notice, "%s", line1 ? line1 : "");
snprintf(g_notice2, sizeof g_notice2, "%s", line2 ? line2 : "");
g_notice_ms = 1;
g_notice_until = meow_ui_time_ms() + 6000; /* 6s 后由 shelf_on_tick 清掉,不常驻压列表 */
}
/* 扫描/索引载入共用的报告 → 提示条文案(M29 语义不变)。
* 索引里存了 truncated/depth_skipped,载入时如实重建,避免「用索引打开」后提示失真。 */
static void notice_from_report(const shelf_scan_report *rep)
{
const char *dir = READER_SD_ROOT "/Books";
g_notice[0] = 0;
g_notice2[0] = 0;
g_notice_ms = 0;
if (rep->root_missing) {
char line[160];
snprintf(line, sizeof line, "未找到 %s 目录,请放入书籍(TXT)", dir);
notice_set(line, NULL);
} else if (rep->truncated && rep->depth_skipped > 0) {
/* 长句拆两行(480px 单行会被切掉) */
char l1[160], l2[160];
snprintf(l1, sizeof l1, "书籍超过 %d 本,已只显示前 %d 本", SHELF_MAX_BOOKS, SHELF_MAX_BOOKS);
snprintf(l2, sizeof l2, "另有 %d 个文件目录超过 %d 层未显示",
rep->depth_skipped, SHELF_SCAN_MAX_DEPTH);
notice_set(l1, l2);
} else if (rep->truncated) {
char line[160];
snprintf(line, sizeof line, "书籍超过 %d 本,已只显示前 %d 本",
SHELF_MAX_BOOKS, SHELF_MAX_BOOKS);
notice_set(line, NULL);
} else if (rep->depth_skipped > 0) {
char line[160];
snprintf(line, sizeof line, "有 %d 个文件因目录层数超过 %d 层未显示",
rep->depth_skipped, SHELF_SCAN_MAX_DEPTH);
notice_set(line, NULL);
}
}
/* 全量扫描 Books/ 并写回索引(首次无索引 / 刷新 / 索引校验失败时走这里)。
* 索引写盘是纯附加:open 失败(卡满/写保护/无 MeowData)→ 传 NULL 闭包,扫描照常;
* commit 失败只丢缓存。**索引问题绝不影响书架能否打开**。 */
static void scan_books_full(void)
{
const char *dir = READER_SD_ROOT "/Books";
shelf_scan_report rep;
/* 扫描时把根 + 一级目录的 mtime 直接流进索引临时文件(level-2+ 由 writer 过滤掉),
* 不占额外内存、也不二次 readdir。 */
shelf_idx_writer *w = shelf_index_writer_open(dir);
g_nbooks = shelf_scan_books_ex(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep,
w ? shelf_index_writer_dir : NULL, w);
if (w) shelf_index_writer_commit(w, g_books, g_nbooks, &rep); /* 成功/失败都接管 w */
if (rep.root_missing) shelf_index_remove(dir); /* 卡/目录没了:旧索引作废,别留陈旧缓存 */
g_scanned = 1;
notice_from_report(&rep);
}
/* 首次进入书架:先试索引(命中=只 stat 目录 + 读进度,不 readdir);任何异常退回全扫。 */
static void scan_books_first(void)
{
const char *dir = READER_SD_ROOT "/Books";
shelf_scan_report rep;
if (shelf_index_load(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep)) {
g_nbooks = rep.nbooks;
g_scanned = 1;
notice_from_report(&rep);
return;
}
scan_books_full();
}
/* 排队行:folder 模式 = 当前目录 cwd 的导航视图;否则按格式(默认)。
* 纯内存过滤 g_books,不触发扫描(M29/M32 语义不变)。 */
static void build_rows(void)
{
g_nrows = shelf_build_rows_at(g_books, g_nbooks, g_group_folder, g_cwd,
g_rows, SHELF_MAX_ROWS);
}
/* folder 模式下 cwd 失效收缩:目录/书被删(重扫后)时回退到最近的仍有书的祖先。
* 导航视图只能从「有书的目录」进入,所以 count_under==0 即视为不存在。 */
static void nav_validate_cwd(void)
{
char parent[SHELF_GROUP_MAX];
while (g_group_folder && g_cwd[0] &&
shelf_nav_count_under(g_books, g_nbooks, g_cwd) == 0) {
shelf_nav_up(g_cwd, parent, (int)sizeof parent);
snprintf(g_cwd, sizeof g_cwd, "%s", parent);
}
}
/* M30:从阅读页返回时把「刚读完」置顶。只读这一本的进度文件 + 重排内存里的 g_books,
* 不 opendir/不遍历目录(对比全量重扫真机 3s)。重排后 on_enter 照常 build_rows + 重建控件。
* M31:置顶的同时重建这本的副题——只改 ts 不重建的话,副题的「· 读」要等下次全量刷新
* (scan_books 重新格式化)才出现,表现就是「刚读完却没显示已读」。 */
static void promote_just_read(void)
{
if (!g_just_read[0]) return;
for (int i = 0; i < g_nbooks; i++) {
if (strcmp(g_books[i].path, g_just_read) != 0) continue;
uint64_t ts = shelf_ts_of(g_books[i].path);
if (ts > g_books[i].ts) {
g_books[i].ts = ts;
shelf_fmt_sub(&g_books[i]); /* 副题随 ts 重算,「· 读」立即出现 */
shelf_sort_recent(g_books, g_nbooks);
}
break;
}
g_just_read[0] = 0;
}
/* ---- list 数据源(vsource + 段头;container on) ---- */
static void shelf_nav_enter_dir(const char *name); /* 定义在控件装配之后 */
static int shelf_list_count(void *ctx) { (void)ctx; return g_nrows; }
static void shelf_list_bind(int idx, meow_lv_item *item, void *ctx)
{
(void)ctx;
item->icon = NULL;
item->icon_kind = MEOW_LIST_ICON_NONE;
item->right = MEOW_LIST_RIGHT_NONE;
item->on = 0;
item->enabled = 1;
item->user = NULL;
item->title = NULL;
item->subtitle = NULL;
item->section = 0;
if (idx < 0 || idx >= g_nrows) return;
if (g_rows[idx].section) { /* format 段头 */
item->section = 1;
item->title = g_rows[idx].label ? g_rows[idx].label : fmt_name(g_rows[idx].fmt);
item->enabled = 0;
} else if (g_rows[idx].dir) { /* folder 目录行 */
item->title = g_rows[idx].name;
item->subtitle = g_rows[idx].info; /* "N 本" */
item->icon_kind = MEOW_LIST_ICON_FOLDER;
item->right = MEOW_LIST_RIGHT_ARROW;
} else { /* 书行 */
int bi = g_rows[idx].book;
if (bi < 0 || bi >= g_nbooks) return;
item->title = g_books[bi].title;
/* folder 导航视图里书就在当前层,副题用默认「格式 · 大小 [· 读]」;
* 完整深层路径不再重复显示(用户已经逐级走进来了)。format 模式不变。 */
item->subtitle = g_books[bi].sub;
item->icon_kind = MEOW_LIST_ICON_FILE;
item->right = MEOW_LIST_RIGHT_ARROW;
}
}
/* 目录行点击:不能在 SDK 的 tap_at 回调里重建控件(会 free 正在遍历的 list/grid)。
* 只记下目标,等 shelf_on_tap 里 tap_at 返回后再进入。 */
static void shelf_nav_queue_enter(const char *name)
{
snprintf(g_pending_enter, sizeof g_pending_enter, "%s", name ? name : "");
}
static void shelf_list_tapcb(int idx, int op, void *ctx)
{
(void)op; (void)ctx;
if (idx < 0 || idx >= g_nrows || g_rows[idx].section) return;
if (g_rows[idx].dir) { shelf_nav_queue_enter(g_rows[idx].name); return; }
int bi = g_rows[idx].book;
if (bi < 0 || bi >= g_nbooks) return;
/* 点书开读:reader_open 打开书(含页位图)→ push ACT_READER 让阅读页成为 view activity,
* 驱动 reader_on_enter/on_leave(重建/释放页位图);「返回书架」/左缘返回走 page_pop 回书架。 */
if (reader_open(g_books[bi].path) == 0) {
snprintf(g_just_read, sizeof g_just_read, "%s", g_books[bi].path); /* 返回时置顶用 */
meow_view_page_push(ACT_READER);
}
}
/* ---- grid 数据源(meow_lazygrid,4 列封面卡) ----
* format 模式:与 M27 前一致,平铺全部书;folder 模式:当前目录的导航行(目录卡 + 书卡)。 */
static int shelf_grid_count(void *ctx) { (void)ctx; return g_group_folder ? g_nrows : g_nbooks; }
static void shelf_grid_bind(int idx, meow_lazygrid_item *item, void *ctx)
{
(void)ctx;
item->title = NULL; item->subtitle = NULL; item->icon = NULL;
item->enabled = 1; item->user = NULL;
if (g_group_folder) {
if (idx < 0 || idx >= g_nrows) return;
if (g_rows[idx].dir) {
item->title = g_rows[idx].name;
item->subtitle = g_rows[idx].info;
return;
}
int bi = g_rows[idx].book;
if (bi < 0 || bi >= g_nbooks) return;
item->title = g_books[bi].title;
item->subtitle = g_books[bi].sub;
return;
}
if (idx < 0 || idx >= g_nbooks) return;
item->title = g_books[idx].title;
item->subtitle = g_books[idx].sub;
}
/* 标题截断(格内防溢出):按字节收窄(UTF-8 截断可能断码,但仅显示用,断码由字库兜底为 tofu;
* 更稳妥在这里按格子宽度自适应——MVP 简单截 8 字节 + "…")。 */
static void grid_title_cut(const char *src, char *out, int outsz)
{
int n = 0;
while (src[n] && n < (int)sizeof g_books[0].title - 1 && n < 7 * 3) n++; /* ≤7 个 3 字节字 */
if ((size_t)n >= strlen(src)) { strncpy(out, src, (size_t)outsz - 1); out[outsz - 1] = 0; return; }
int m = n; while (m > 0 && ((unsigned char)src[m]) > 0x7F && m > n - 2) m--; /* 断码回退 */
if (m < 0) m = 0;
strncpy(out, src, (size_t)m); out[m] = 0;
size_t L = strlen(out);
if (L + 4 < (size_t)outsz) { out[L] = ' '; out[L + 1] = ' '; out[L + 2] = '.'; out[L + 3] = '.'; out[L + 4] = '.';
out[L + 5] = 0; }
}
/* 宫格标题按可用像素宽截断(folder 模式用:目录名/书名可能比格子宽,居中绘制会溢出到邻格)。 */
static void grid_title_fit_px(const char *src, char *out, int outsz, int max_px)
{
snprintf(out, (size_t)outsz, "%s", src ? src : "");
if (meow_ui_text_width(out, 16) <= max_px) return;
size_t n = strlen(out);
char tmp[160];
while (n > 0) {
do { n--; } while (n > 0 && ((unsigned char)out[n] & 0xC0) == 0x80);
out[n] = 0;
snprintf(tmp, sizeof tmp, "%s...", out);
if ((int)strlen(tmp) < outsz && meow_ui_text_width(tmp, 16) <= max_px) {
snprintf(out, (size_t)outsz, "%s", tmp);
return;
}
}
snprintf(out, (size_t)outsz, "...");
}
static void shelf_grid_draw(int index, int x, int y, int w, int h, int state, void *ctx)
{
(void)ctx;
const meow_theme *th = meow_theme_get();
int is_dir = 0;
int fmt = 0;
const char *title = NULL;
const char *badge = NULL;
if (g_group_folder) {
if (index < 0 || index >= g_nrows) return;
const shelf_row *r = &g_rows[index];
if (r->dir) {
is_dir = 1; title = r->name; badge = r->info; /* "12 本" */
} else {
int bi = r->book;
if (bi < 0 || bi >= g_nbooks) return;
fmt = g_books[bi].fmt; title = g_books[bi].title; badge = fmt_name(fmt);
}
} else {
if (index < 0 || index >= g_nbooks) return;
fmt = g_books[index].fmt; title = g_books[index].title; badge = fmt_name(fmt);
}
uint32_t card = th->surfaceContainerLow;
if (state & MEOW_LAZYGRID_ST_SELECTED) card = th->primaryContainer;
meow_gfx_fill_round(x, y, w, h, th->radius_card, card);
int pad = 8;
int cw = w - 2 * pad, chh = h - 2 * pad - meow_ui_text_height(16) - 6;
int cx = x + pad, cy = y + pad;
if (cw > 0 && chh > 0) {
/* 目录卡用 secondaryContainer 内块 + 书数徽标,与书卡的格式徽标区分。 */
meow_gfx_fill_round(cx, cy, cw, chh, MEOW_SHAPE_SMALL,
is_dir ? th->secondaryContainer : th->surfaceContainerHigh);
int fs = 24;
meow_ui_text_center(cx + cw / 2, cy + (chh - meow_ui_text_height(fs)) / 2, badge, fs,
is_dir ? th->onSecondaryContainer : th->primary);
}
char t[128];
if (g_group_folder) grid_title_fit_px(title ? title : "", t, sizeof t, w - 8);
else grid_title_cut(title ? title : "", t, sizeof t);
meow_ui_text_center(x + w / 2, y + h - meow_ui_text_height(16) - 2, t, 16, th->onSurface);
if (state & MEOW_LAZYGRID_ST_SELECTED) {
meow_gfx_fill_round_alpha(x, y, w, h, th->radius_card, th->primary, 26);
}
}
static void shelf_grid_tapcb(int idx, void *ctx)
{
(void)ctx;
int bi = idx;
if (g_group_folder) {
if (idx < 0 || idx >= g_nrows) return;
if (g_rows[idx].dir) { shelf_nav_queue_enter(g_rows[idx].name); return; }
bi = g_rows[idx].book;
}
if (bi < 0 || bi >= g_nbooks) return;
if (reader_open(g_books[bi].path) == 0) {
snprintf(g_just_read, sizeof g_just_read, "%s", g_books[bi].path); /* 返回时置顶用 */
meow_view_page_push(ACT_READER);
}
}
/* ---- 控件装配(按当前布局重建 list / grid) ---- */
static void shelf_build_content(void)
{
const meow_theme *th = meow_theme_get();
int sbh = meow_ui_statusbar_height();
int top = sbh + SHELF_APPBAR_H;
int list_y = top + SHELF_SUB_H; /* 副题区下(与 shelf_content_top 同口径) */
int list_h = 720 - list_y;
if (list_h < 0) list_h = 0;
/* 控件重建(进/退目录、布局/分组切换、刷新):在途滚动手势的旧 g_list/g_grid 已失效,
* 清掉拖动状态,避免上一次拖动遗留的「已滚过」标记吃掉下一次抬手滚动。 */
shelf_drag_reset();
if (g_list) { meow_widget_free(g_list); g_list = NULL; }
if (g_grid) { meow_lazygrid_free(g_grid); g_grid = NULL; }
if (g_layout == 1) { /* grid */
int cols = 4, gap = 8;
int cw = (SHELF_LIST_W - (cols + 1) * 8) / cols; /* 16 边距 */
int chh = cw + 30; /* 封面 + 标题 */
g_grid = meow_lazygrid_create(SHELF_LIST_X, list_y, SHELF_LIST_W, list_h, cols, cw, chh, gap);
if (g_grid) {
meow_lazygrid_set_vsource(g_grid, shelf_grid_count, shelf_grid_bind, NULL);
meow_lazygrid_set_item_draw(g_grid, shelf_grid_draw, NULL);
meow_lazygrid_set_on_tap(g_grid, shelf_grid_tapcb, NULL);
}
} else { /* list */
g_list = meow_listview_create(SHELF_LIST_X, list_y, SHELF_LIST_W, list_h,
g_nrows, NULL, NULL, MEOW_LIST_SEL_SINGLE);
if (g_list) {
meow_listview_set_vsource(g_list, shelf_list_count, shelf_list_bind, NULL);
/* MD3 圆角容器(surfaceContainer 卡)走 meow_gfx_fill_round 逐像素 rr_inside:
* 真机一张 464×54 卡 3.8–6.4ms、可见 11 行≈42ms/帧(滑一步 46ms)。行数多时
* 自动降级为扁平行(survey 实测同滚动 14ms),行数少(≤SHELF_ROUND_MAX_ROWS)
* 仍保留圆角卡观感。 */
meow_listview_set_container(g_list, g_nrows > SHELF_ROUND_MAX_ROWS ? 0 : 1);
meow_listview_set_item_tier(g_list, 2);
meow_listview_set_on_tap(g_list, shelf_list_tapcb, NULL);
}
}
(void)th;
}
/* 进入子目录:改 cwd → 内存重排行序列 → 重建顶栏/内容。不扫描、不读盘。 */
static void shelf_nav_enter_dir(const char *name)
{
char next[SHELF_GROUP_MAX];
if (!shelf_nav_enter(g_cwd, name, next, (int)sizeof next)) return;
snprintf(g_cwd, sizeof g_cwd, "%s", next);
build_rows();
shelf_appbar_build();
shelf_build_content();
shelf_redraw();
}
/* 返回上一级(左缘返回手势 / 顶栏返回箭头)。1=已回退;0=已在根(调用方决定退出 App)。
* folder 模式之外恒返回 0——format 模式保持原有「左滑退出」行为。 */
int shelf_back(void)
{
if (!g_group_folder || !g_cwd[0]) return 0;
char parent[SHELF_GROUP_MAX];
if (!shelf_nav_up(g_cwd, parent, (int)sizeof parent)) return 0;
snprintf(g_cwd, sizeof g_cwd, "%s", parent);
build_rows();
shelf_appbar_build();
shelf_build_content();
shelf_redraw();
return 1;
}
/* 顶栏标题按可用像素宽截断(UTF-8 安全,末尾 "…")。
* 动作区固定占右侧约 200px(宫格/格式两枚文字动作 + 刷新/设置两枚 48px 图标格),
* 返回箭头占左侧 48px;目录名较长时(如 "400+本高质量完本合集")必须让位,否则压在动作上。 */
static void appbar_title_fit(const char *src, char *out, int outsz, int max_px)
{
const meow_theme *th = meow_theme_get();
int fsz = (th && th->font_title > 0) ? th->font_title : 28;
snprintf(out, (size_t)outsz, "%s", src ? src : "");
if (meow_ui_text_width(out, fsz) <= max_px) return;
size_t n = strlen(out);
char tmp[128];
while (n > 0) {
do { n--; } while (n > 0 && ((unsigned char)out[n] & 0xC0) == 0x80);
out[n] = 0;
snprintf(tmp, sizeof tmp, "%s…", out);
if ((int)strlen(tmp) < outsz && meow_ui_text_width(tmp, fsz) <= max_px) {
snprintf(out, (size_t)outsz, "%s", tmp);
return;
}
}
snprintf(out, (size_t)outsz, "…");
}
/* 顶栏动作:0=布局切换 1=分组切换 2=刷新 3=设置。动作标签 = 将切换到的模式。
* folder 模式下顶栏标题 = 当前目录名(根="书架"),并有返回箭头;面包屑全路径在副题行。 */
static void shelf_appbar_build(void)
{
int sbh = meow_ui_statusbar_height();
if (g_appbar) { meow_widget_free(g_appbar); g_appbar = NULL; }
char title[64] = "书架";
int show_back = 0;
if (g_group_folder && g_cwd[0]) {
char name[64];
shelf_dir_name(g_cwd, name, (int)sizeof name);
appbar_title_fit(name, title, (int)sizeof title, 205);
show_back = 1;
}
g_appbar = meow_topappbar_create(0, sbh, 480, SHELF_APPBAR_H, title);
if (!g_appbar) return;
if (show_back) meow_topappbar_set_back(g_appbar, 1);
meow_topappbar_add_action(g_appbar, g_layout ? "列表" : "宫格"); /* 0 布局切换 */
meow_topappbar_add_action(g_appbar, g_group_folder ? "格式" : "目录"); /* 1 分组切换 */
meow_topappbar_add_action(g_appbar, "刷新"); /* 2 刷新 */
meow_topappbar_add_action(g_appbar, "设置"); /* 3 设置 */
meow_topappbar_set_item_icon(g_appbar, 2, MEOW_ICON_REFRESH);
meow_topappbar_set_item_icon(g_appbar, 3, MEOW_ICON_SETTINGS);
}
/* view 生命周期 */
static void shelf_on_create(meow_view *v, void *ctx) { (void)v; (void)ctx; }
static void shelf_on_enter(meow_view *v, void *ctx)
{
(void)v; (void)ctx;
g_active = 1;
/* 先读配置(build_rows 依赖分组模式),再扫描/排队。 */
const rdconf *c = rdconf_get();
g_layout = (c && c->shelf[0] && strcmp(c->shelf, "grid") == 0) ? 1 : 0;
g_group_folder = (c && strcmp(c->shelf_group, "folder") == 0) ? 1 : 0;
/* 书架缓存:本进程只在首次进入时取一次 Books/(从阅读页返回不再重扫)。
* M32:首次先试索引(命中=只 stat 目录,跳过 readdir);索引不可用才全量扫描。
* 真机冷缓存实测:全量扫描首开 3.43–3.48s,索引命中首开 0.13–0.16s(M32 r2 报文);
* readdir 是扫描的唯一大头,而冷缓存下 434 次 stat 也要 3.15s——所以 v2 只校验根+一级。
* 代价:进程内不再自动发现新书,需点顶栏「刷新」;跨进程重启则由索引 mtime 校验兜底。
* M30:刚读完那本例外——promote_just_read() 只读它一个进度文件并重排,低成本置顶。
* 提示条(超上限截断 / 超深目录)语义不变:索引里存了这两个值,载入时如实重建。 */
if (!g_scanned) scan_books_first();
else promote_just_read();
nav_validate_cwd(); /* folder 模式:cwd 若已失效(目录/书被删)回退到最近的祖先 */
build_rows();
const meow_theme *th = meow_theme_get();
g_bg = th->bg; g_fg = th->fg; g_dim = th->dim;
shelf_appbar_build();
shelf_build_content();
shelf_redraw();
}
static void shelf_on_leave(meow_view *v, void *ctx) { (void)v; (void)ctx; g_active = 0; }
static void shelf_on_destroy(meow_view *v, void *ctx)
{
(void)v; (void)ctx;
if (g_list) { meow_widget_free(g_list); g_list = NULL; }
if (g_grid) { meow_lazygrid_free(g_grid); g_grid = NULL; }
if (g_appbar) { meow_widget_free(g_appbar); g_appbar = NULL; }
}
meow_view *shelf_view_create(void)
{
static meow_view_hooks hooks = { shelf_on_create, shelf_on_enter, shelf_on_leave,
shelf_on_destroy, NULL, NULL, NULL };
return meow_view_create(ACT_SHELF, MEOW_VIEW_ACTIVITY, &hooks, NULL);
}
int shelf_is_active(void) { return g_active; }
/* 书架 tick(app.c 在书架为前台页时调用):提示条 6s 到期自动消失。 */
void shelf_on_tick(uint64_t now)
{
if (g_notice_ms > 0 && now >= g_notice_until) {
g_notice_ms = 0;
g_notice_until = 0;
shelf_redraw();
}
}
/* 切换 list/grid(书架顶栏「布局切换」动作;改 reader.conf 并重建)。 */
void shelf_set_view(int grid)
{
rdconf *c = rdconf_get();
if (!c) return;
strcpy(c->shelf, grid ? "grid" : "list");
rdconf_persist();
g_layout = grid ? 1 : 0;
if (g_active) {
shelf_appbar_build();
shelf_build_content();
shelf_redraw();
}
}
/* 切换 format/folder(顶栏「分组」动作 / 设置页「书架分组」;改 reader.conf 并重建)。
* 只改分组模式:不碰 books[].path,故进度键 rdprog_hash(path) 不变。 */
void shelf_set_group(int folder)
{
rdconf *c = rdconf_get();
if (!c) return;
strcpy(c->shelf_group, folder ? "folder" : "format");
rdconf_persist();
g_group_folder = folder ? 1 : 0;
/* M33:folder↔format 切换保留 cwd(切回 folder 仍在原目录);失效则收缩到祖先。
* 只改分组模式:不碰 books[].path,也不重扫(M29 g_scanned 语义不变)。 */
nav_validate_cwd();
if (g_active) {
build_rows();
shelf_appbar_build();
shelf_build_content();
shelf_redraw();
}
}
/* 提示条(扫描/索引报告,6s 后由 shelf_on_tick 清掉):滚动只重画内容区时也要重贴,
* 否则滑一下就把提示抹掉了。 */
static void shelf_draw_notice(void)
{
if (g_notice_ms <= 0) return;
const meow_theme *th = meow_theme_get();
int two = g_notice2[0] != 0;
int top = two ? 610 : 620;
int h = two ? 58 : 44;
meow_ui_fill_rect(0, top, 480, h, th->bg);
meow_ui_text(20, top + 4, g_notice, 20, th->primary);
if (two) meow_ui_text(20, top + 30, g_notice2, 20, th->primary);
}
void shelf_redraw(void)
{
const meow_theme *th = meow_theme_get();
g_bg = th->bg; g_fg = th->fg; g_dim = th->dim;
meow_ui_clear(g_bg);
if (g_appbar) meow_widget_draw(g_appbar);
/* 副题:format = "Books/ · N 本"(M27 前逐字不变);
* folder = 面包屑全路径 + 当前层子树书数,如 "Books/400+…/01、玄幻 · 12 本 · 按目录"。
* 顶栏另显示当前目录名,两者配合可看出「在哪 + 下面还有多少本」。 */
if (g_nbooks > 0) {
char sub[128];
if (g_group_folder) {
char bc[400], tmp[512];
int total = shelf_nav_count_under(g_books, g_nbooks, g_cwd);
shelf_breadcrumb(g_cwd, bc, (int)sizeof bc);
snprintf(tmp, sizeof tmp, "%s,%d 本,按目录", bc, total);
shelf_copy_utf8(sub, (int)sizeof sub, tmp);
} else {
snprintf(sub, sizeof sub, "Books/ · %d 本", g_nbooks);
}
int sbh = meow_ui_statusbar_height();
meow_ui_text(24, sbh + SHELF_APPBAR_H + 3, sub, 16, g_dim);
}
if (g_nbooks > 0) {
if (g_list) meow_widget_draw(g_list);
if (g_grid) meow_lazygrid_draw(g_grid, th);
} else {
/* 无书也渲染完整骨架(顶栏已在)+ 居中空态卡 + 指引 + 支持格式 */
int sbh = meow_ui_statusbar_height();
int top = sbh + SHELF_APPBAR_H;
int area_h = 720 - top;
int cw = 360, chh = 200;
int cx = (480 - cw) / 2;
int cy = top + (area_h - chh) / 2;
if (cy < top) cy = top;
meow_gfx_fill_round(cx, cy, cw, chh, th->radius_card, th->surfaceContainer);
meow_ui_text_center(480 / 2, cy + 40, "书架为空", 28, th->fg);
meow_ui_text_center(480 / 2, cy + 76, "请往 TF 卡 Books/ 放书籍", 20, th->onSurfaceVariant);
meow_ui_text_center(480 / 2, cy + 104, "支持 TXT / FB2 / EPUB / MOBI / CBZ", 20, th->onSurfaceVariant);
/* 「去放书」文本按钮(提示) */
meow_ui_text_center(480 / 2, cy + 150, "Books/ 目录", 20, th->primary);
}
if (g_notice_ms > 0) shelf_draw_notice();
meow_ui_flush();
}
/* ---- M41 滚动:唯一入口(swipe / 跟手拖动都走这里)----
* 历史 bug:SHELF 从来没有滚动输入通路——①`app_on_touch_down/move` 只路由给阅读页,
* 所以没有跟手拖动;②`shelf_on_swipe` 用 dy 去 scroll_by,而 SDK 固定按
* `on_swipe(dir, dist, 0)` 分发(位移在第 2 参 dx,dy 恒 0)→ scroll_by(0) 恒为死代码。
* list/grid、format/folder 都走同一段,所以两种分组、两种布局全都「滑不动」。 */
static int shelf_content_top(void)
{
return meow_ui_statusbar_height() + SHELF_APPBAR_H + SHELF_SUB_H; /* = shelf_build_content 的 list_y */
}
static int shelf_content_bottom(void)
{
int h = meow_ui_height();
return h > 0 ? h : 720;
}
/* 当前布局的滚动上限(0=内容不足一屏,无需滚动;也是「根层 3 行不该动」的判据)。 */
static int shelf_scroll_max(void)
{
if (g_layout == 1) return g_grid ? meow_lazygrid_max_scroll(g_grid) : 0;
return g_list ? meow_listview_max_scroll(g_list) : 0;
}
static int shelf_scroll_get(void)
{
if (g_layout == 1) return g_grid ? meow_lazygrid_scroll(g_grid) : 0;
return g_list ? meow_listview_scroll(g_list) : 0;
}
/* 只重画内容区:顶栏/副题/提示不动,避免整帧重绘(滚动每帧都走这里)。 */
static void shelf_redraw_content(void)
{
const meow_theme *th = meow_theme_get();
int top = shelf_content_top();
int h = shelf_content_bottom() - top;
if (h <= 0) return;
meow_ui_fill_rect(0, top, SHELF_LIST_W, h, th->bg);
if (g_layout == 1 && g_grid) meow_lazygrid_draw(g_grid, th);
else if (g_list) meow_widget_draw(g_list);
shelf_draw_notice();
meow_ui_flush_region(0, top, SHELF_LIST_W, h);
}
/* 只改滚动位置(不动屏幕):返回 1=位置变了。 */
static int shelf_scroll_apply(int del)
{
if (!del) return 0;
int before = shelf_scroll_get();
if (g_layout == 1) { if (g_grid) meow_lazygrid_scroll_by(g_grid, del); }
else { if (g_list) meow_listview_scroll_by(g_list, del); }
return shelf_scroll_get() != before;
}
/* 改位置 + 立即上屏:swipe 的一次性滚动走这里。 */
static int shelf_scroll_by(int del)
{
if (!shelf_scroll_apply(del)) return 0;
shelf_redraw_content();
return 1;
}
/* 拖动中的重绘:把已生效的滚动上屏,并记录帧耗时给节流窗口用。 */
static void shelf_drag_flush(void)
{
if (!g_drag_dirty) return;
uint64_t t0 = meow_ui_time_ms();
shelf_redraw_content();
uint64_t t1 = meow_ui_time_ms();
g_drag_dirty = 0;
g_drag_draw_ms = t0 ? t0 : 1; /* 记**起始**时刻:下一帧的周期 ≈ max(16ms, 本帧耗时)。0 是「没画过」哨兵 */
g_drag_frame_ms = (t1 > t0) ? (int)(t1 - t0) : 0; /* 10ms 分辨率:小帧记 0 → 窗口仍 ≥16ms */
}
static void shelf_drag_reset(void)
{
g_drag_state = 0;
g_drag_scrolled = 0;
g_drag_dirty = 0;
g_drag_draw_ms = 0;
g_drag_frame_ms = 0;
}
/* 手势收尾:节流窗内没来得及上屏的最后一段**必须补一帧**,否则抬手后画面停在旧位置
* (滚动位置已经生效,视觉上就是「拖了但没跟上」)。 */
static void shelf_drag_finish(void)
{
if (g_drag_state == 2 && g_drag_dirty) shelf_drag_flush();
shelf_drag_reset();
}
void shelf_on_touch_down(int x, int y)
{
shelf_drag_finish(); /* 上一次手势若没走 TAP/SWIPE 收尾(HOME/长按等),先把遗留帧补上 */
if (!g_active || g_nbooks <= 0) return;
if (y < shelf_content_top() || y >= shelf_content_bottom()) return; /* 顶栏/副题不拖 */
if (shelf_scroll_max() <= 0) return; /* 不足一屏:没有可滚的 */
g_drag_state = 1;
g_drag_x0 = x; g_drag_y0 = y; g_drag_last_y = y;
}
void shelf_on_touch_move(int x, int y)
{
if (g_drag_state != 1 && g_drag_state != 2) return; /* 无手势 / 横向手势已取消 */
int adx = x - g_drag_x0, ady = y - g_drag_y0;
if (adx < 0) adx = -adx;
if (ady < 0) ady = -ady;
if (g_drag_state == 1) {
if (ady >= SHELF_DRAG_START_PX && ady >= adx) g_drag_state = 2; /* 纵向主导 → 滚动 */
else if (adx >= SHELF_DRAG_START_PX && adx > ady) { g_drag_state = 3; return; } /* 横向(左缘返回等):不滚 */
else return;
}
int step = y - g_drag_last_y;
g_drag_last_y = y;
if (!step) return;
/* 位置每次都立即生效(便宜);上屏节流:首帧立即画,之后每 max(16ms, 上一帧耗时) 一次。
* 输入线程每条 MOVE 都入队且不合并,逐条全量重绘会让队列永远追不上手指。 */
if (!shelf_scroll_apply(-step)) return; /* 已到顶/底:位置没变,连帧都不用画 */
g_drag_scrolled = 1; /* 手指上移(y 减)→ 显示更下面的行 */
g_drag_dirty = 1;
uint64_t now = meow_ui_time_ms();
int win = g_drag_frame_ms > SHELF_DRAG_FRAME_MS ? g_drag_frame_ms : SHELF_DRAG_FRAME_MS;
if (g_drag_draw_ms == 0 || (now >= g_drag_draw_ms && now - g_drag_draw_ms >= (uint64_t)win))
shelf_drag_flush();
}
int shelf_on_tap(int x, int y)
{
/* 拖动后的抬手不是点击:累计纵向位移 ≥ SHELF_DRAG_TAP_PX 就吞掉这次 TAP,
* 否则「滑动时手指落点在一行书上」会把书打开(位移 <65px 的拖动 SDK 报 TAP)。 */
int dragged = (g_drag_state == 2 &&
(g_drag_scrolled ||
(g_drag_last_y - g_drag_y0 > SHELF_DRAG_TAP_PX) ||
(g_drag_y0 - g_drag_last_y > SHELF_DRAG_TAP_PX)));
shelf_drag_finish(); /* 先补终帧再清手势态 */
if (dragged) return 0;
/* 顶栏动作先命中 */
if (g_appbar) {
int bi = meow_topappbar_hit(g_appbar, x, y);
if (bi == MEOW_TOPBAR_BACK) { /* M33:顶栏返回箭头 = 上一级(根层不显示此钮) */
shelf_back();
return 1;
}
if (bi == 0) { /* 布局切换 */
shelf_set_view(g_layout ? 0 : 1);
return 1;
} else if (bi == 1) { /* 分组切换:format ↔ folder */
shelf_set_group(g_group_folder ? 0 : 1);
return 1;
} else if (bi == 2) { /* 刷新:清索引 + 全量重扫(新书/改名/删书在此出现) */
g_scanned = 0;
font_cache_invalidate(); /* 字体路径记忆化也一并失效(新拷入的字库在此生效) */
/* 先删索引再全量扫描:即使旧索引 mtime 校验碰巧通过(如文件系统时间戳精度不足),
* 刷新也一定重扫;扫描成功后写回新索引(写失败只丢缓存)。 */
shelf_index_remove(READER_SD_ROOT "/Books");
scan_books_full();
nav_validate_cwd(); /* M33:刷新后 cwd 目录可能已被删 → 收缩到仍有书的祖先 */
build_rows();
shelf_build_content();
shelf_redraw();
return 1;
} else if (bi == 3) { /* App 设置入口 */
meow_view_page_push(ACT_SETTINGS);
return 1;
}
}
if (g_nbooks <= 0) { shelf_redraw(); return 0; }
if (g_layout == 1 && g_grid) {
int idx = meow_lazygrid_tap_at(g_grid, x, y);
if (g_pending_enter[0]) {
char name[SHELF_GROUP_MAX];
snprintf(name, sizeof name, "%s", g_pending_enter);
g_pending_enter[0] = 0;
shelf_nav_enter_dir(name); /* tap_at 已返回,重建控件安全 */
} else if (idx >= 0) shelf_redraw();
return 0;
}
if (g_list) {
int idx = meow_listview_tap_at(g_list, x, y);
if (g_pending_enter[0]) {
char name[SHELF_GROUP_MAX];
snprintf(name, sizeof name, "%s", g_pending_enter);
g_pending_enter[0] = 0;
shelf_nav_enter_dir(name); /* tap_at 已返回,重建控件安全 */
} else if (idx >= 0) shelf_redraw();
return 0;
}
return 0;
}
/* 书架的滚动(list/grid;app.c on_swipe 路由到本页时调用)。
* SDK 契约:`on_swipe(dir, dist, 0)` —— 总位移在**第 2 参**,dy 恒 0(见 meow_ui_input.c
* MEOW_EV_SWIPE 分发)。旧实现按 dy 滚,等于永远滚 0,这就是「怎么划都不动」的直接原因。
* 跟手拖动已经在 on_touch_move 里滚过本次手势时,抬手不再叠加一次(否则一划滚两倍)。 */
void shelf_on_swipe(int dir, int dx, int dy)
{
int already = (g_drag_state == 2 && g_drag_scrolled);
shelf_drag_finish(); /* 先补终帧(节流窗内最后一段)再清手势态 */
if (g_nbooks <= 0 || already) return;
if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return;
int dist = dx > 0 ? dx : dy; /* SDK: dist 在 dx;dy 分支只为兼容「位移放 dy」的旧/宿主编排 */
if (dist <= 0) return;
shelf_scroll_by(dir == MEOW_SWIPE_UP ? dist : -dist);
}