Files
MeowOS-SDK/tuku/ui/library/library.c
T
AskaEth 4befbfe14c 图库: 书库点图改按文件名定位(修 P1 截断错位)+ 册间衔接根锁定(M18 评审 r1)
P1-1(会静默开错图):取消「书库从列表推导图片下标」。
- 新增 mr_reader_open_dir_at_name(dir, name):阅读器打开目录册后,在**自己刚枚举出的
  全量目录册**(mr_dir_book,与翻页同一份枚举)里按 basename 定位起始页(先精确,
  再大小写不敏感兜底;找不到退回续读/首页)。书库点击只传文件名。
- 删除 mr_library_image_index(不再有跨枚举的隐式契约)。
- 截断提示文案改为「仅显示前 2000 项(图片可在阅读器中翻到)」。
- 修复前(cb02722 探针,2100 张 4 位名夹具):rows=2000/truncated=1/
  library_images=2000/reader_pages=2100/**mismatched=590**(首行 p1412 → 打开 p1411);
  评审夹具为 1995/2000。修复后:2000 个可见图片行按名定位 **0 mismatch**,
  2100 页全量可翻;26 次真实 UI 点开(首尾各 3 + 20 个确定性伪随机)逐张命中。

连带处理(评审 r1):
1. 删掉 reader_page_enter 的 c->view 死写(reopen 判定用 activity active/depth);
   顺手删掉 library_page_create 里同款死写。
2. 阅读器册间衔接根锁定:新增 mr_path_within_root + mr_book_adjacent_rooted,
   book_switch 用浏览根(默认 MR_ROOT_DIR,测试可注入)钳制相邻册;
   根外的兄弟册按「没有相邻册」处理。test_cont_adjacent A7 验证「明文 _ex 会越根、
   rooted 拒绝、根内翻册不受影响」;test_mode_switch_host H6 验证条漫末页不越根。
3. test_reader_perf 不再依赖顶栏 tap 命中时序:模式切换改走测试钩子
   mr_reader_test_toggle_mode()(与顶栏动作同一个 mode_set),负载下稳定。
4. page_cache 注释去掉「缩略图缓存/缩略图线程」过时描述(只改文字)。

回归:run_tests.sh(library 81 / nav 74 / lazy 57 / perf 25 / … / open_book 含 2100 用例)
与 run_webtoon_tests.sh(含 A7/H6)全 PASS;交叉构建零警告。
2026-11-05 20:26:40 +08:00

582 lines
19 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.
/* ui/library/library.c — 图库浏览页(ACT_LIBRARY):文件管理器模型。
*
* TopAppBar(标题 + 返回 + 刷新 + ⚙)+ meow_listview(src2 行数据源)。
*
* 浏览语义(M18,用户要求「完全像文件管理器」):
* - 列表 = 当前目录一层的「子目录 + 受支持图片 + .cbz/.zip 档案册」,目录优先、组内自然序;
* 点目录=进入(空目录也进),点图片=从该图开始读(mr_reader_open_dir_at_name(目录, 文件名),
* 由阅读器在全量目录册里定位起始页;列表被截断也不会点错图),
* 点档案册=打开档案册(第 1 页/续读);
* - 首帧只做**单层**枚举(一次 opendir/readdir+lstat):没有「目录含图=一本书」的探测、
* 页数、封面、角标,也没有懒算线程/缩略图解码——行副标题在扫描期生成一次;
* - 根目录锁定 TF 卡根(MR_ROOT_DIR):顶栏返回在根隐藏,library_go_up 在根返回 0
* (app.c 据此退出 App),不存在越过根上溯的导航;
* - 根目录不可访问(未插卡/挂载失败)→ 列表空态「未检测到 SD 卡」+ 顶栏刷新重试;
* 重进本页也会自动重扫。子目录不可读 → 停留原目录 + 顶部提示横幅。
*
* 行绑定(src2 模式):meow_listview_create(..., lib_src, ...) 每帧对可见行取一次行数据;
* lib_src 只读 g_lib 内存(指针直出,不做 IO/格式化),滚动与点击路径零解码零文件访问。
*
* 绘制/颜色全部取 meow_theme_get() 语义色;主题切换由 app.c 的 listener 整帧 invalidate。
*/
#include "tuku_internal.h"
#include "ui/reader/reader.h"
#include <errno.h>
#include <stdio.h>
#include <string.h>
#define MR_TOPAPPBAR_H 56
#define MR_HINT_H 44
#define MR_HINT_MS 2500
static mr_page_ctrl g_ctrl;
static mr_library g_lib;
static char g_cur_dir[MR_PATH_MAX] = MR_ROOT_DIR;
static char g_root_dir[MR_PATH_MAX] = MR_ROOT_DIR; /* 浏览根(生产=MR_ROOT_DIR,宿主测试可改) */
static meow_widget *g_tb;
static meow_widget *g_lv;
static meow_scroll *g_scroll;
static int g_list_x, g_list_y, g_list_w, g_list_h;
static int g_act_refresh = -1;
static int g_act_settings = -1;
static int g_tb_back; /* 顶栏返回箭头是否显示(根目录隐藏) */
static int g_sd_missing; /* 1=根目录不可访问(未检测到 SD 卡的空态) */
/* ---- 顶部提示横幅(子目录不可读等) ---- */
static int g_hint_id;
static char g_hint[160];
static uint64_t g_hint_until;
static int g_hint_x, g_hint_y, g_hint_w, g_hint_h;
static int g_drag;
static int g_dragged;
static int g_drag_last_y;
static int g_press_row; /* 按下行(-1=无):按下高亮,拖动超阈值清除 */
static int g_press_delta;
/* 清除按下高亮(抬手/拖动/切目录时调用)。 */
static void press_clear(void)
{
if (g_lv) meow_listview_set_sel(g_lv, -1);
g_press_row = -1;
g_press_delta = 0;
}
/* ============================================================ */
/* 行数据源(src2:每帧按需取可见行;零 IO、零格式化) */
/* ============================================================ */
static int lib_src(int idx, meow_list_item *item, void *ctx)
{
(void)ctx;
memset(item, 0, sizeof *item);
mr_library_row row;
if (!mr_library_row_at(&g_lib, idx, &row)) return 0;
item->title = row.title;
item->subtitle = row.subtitle;
if (row.disabled) {
item->enabled = 0;
return 1;
}
item->enabled = 1;
if (row.kind == MR_LIB_KIND_DIR) {
item->icon_kind = MEOW_LIST_ICON_FOLDER;
item->right = MEOW_LIST_RIGHT_ARROW; /* 目录=可进入 */
} else {
item->icon_kind = MEOW_LIST_ICON_FILE; /* 图片/档案册=文件符号 */
item->right = MEOW_LIST_RIGHT_NONE; /* 点开即读,无子级 */
}
return 1;
}
/* ============================================================ */
/* 布局 */
/* ============================================================ */
static int lib_top_y(void)
{
return meow_ui_statusbar_height() + MR_TOPAPPBAR_H;
}
static void hint_place(void)
{
g_hint_x = 0;
g_hint_y = lib_top_y();
g_hint_w = meow_ui_width();
g_hint_h = MR_HINT_H;
}
static const char *empty_text_for_state(void)
{
if (g_sd_missing) return "未检测到 SD 卡(插卡后点右上角刷新)";
return "空文件夹";
}
static void list_rebuild(void)
{
if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; }
if (g_lv) { meow_widget_free(g_lv); g_lv = NULL; }
g_list_x = 0;
g_list_y = lib_top_y();
g_list_w = meow_ui_width();
g_list_h = meow_ui_height() - g_list_y;
if (g_list_h < 1) g_list_h = 1;
int rows = g_lib.count + (g_lib.truncated ? 1 : 0);
g_lv = meow_listview_create(g_list_x, g_list_y, g_list_w, g_list_h, rows,
lib_src, NULL, MEOW_LIST_SEL_SINGLE);
if (!g_lv) return;
meow_listview_set_item_renderer(g_lv, 2, 1, 1);
meow_listview_set_item_tier(g_lv, 2);
/* SDK 默认 container=0(扁平满幅+分隔线);显式开 MD3 容器模式(与文件管理器一致)。 */
meow_listview_set_container(g_lv, 1);
meow_listview_set_empty_text(g_lv, empty_text_for_state());
g_scroll = meow_scroll_wrap(g_lv, 1);
/* 不设 snap:文件管理器手感是自由滚动,逐行吸附会发黏。 */
}
static void update_topbar(void)
{
if (!g_tb) return;
const char *title = "图库";
if (strcmp(g_cur_dir, g_root_dir) != 0) {
const char *slash = strrchr(g_cur_dir, '/');
if (slash && slash[1]) title = slash + 1;
}
meow_topappbar_set_title(g_tb, title);
/* 根目录锁定:根=浏览天花板,顶栏不显示返回/上级。 */
g_tb_back = (strcmp(g_cur_dir, g_root_dir) != 0);
meow_topappbar_set_back(g_tb, g_tb_back);
}
static void list_invalidate(void)
{
meow_ui_invalidate(g_list_x, g_list_y, g_list_w, g_list_h);
}
static void hint_invalidate(void)
{
meow_ui_invalidate(g_hint_x, g_hint_y, g_hint_w, g_hint_h);
}
static void show_hint(const char *msg)
{
snprintf(g_hint, sizeof g_hint, "%s", msg ? msg : "");
g_hint_until = meow_ui_time_ms() + MR_HINT_MS;
hint_place();
hint_invalidate();
}
/* 顶部横幅绘制物:只在提示有效且本页 active 时输出(custom 绘制物无 owner 闸门,必须自守)。 */
static void library_draw_hint(void *ctx)
{
(void)ctx;
if (meow_ui_activity_active() != ACT_LIBRARY) return;
if (!g_hint[0]) return;
const meow_theme *th = meow_theme_get();
int x = g_hint_x, y = g_hint_y, w = g_hint_w, h = g_hint_h;
if (w < 40 || h < 20) return;
meow_ui_fill_rect(x + 8, y + 4, w - 16, h - 8, th->surfaceContainerHighest);
meow_ui_draw_rect(x + 8, y + 4, w - 16, h - 8, th->error);
int ty = y + (h - meow_ui_text_height(th->font_body)) / 2;
meow_ui_text(x + 20, ty, g_hint, th->font_body, th->error);
}
/* ============================================================ */
/* 目录加载 */
/* ============================================================ */
static void repaint_all(void)
{
if (g_ctrl.active)
meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height());
}
/* 切到 target(空/失败列表也会切);成功 1,目录不可读 0。
* 根目录不可读 → SD 卡空态;子目录不可读 → 停留原目录 + 提示。 */
static int load_dir(const char *dir)
{
/* dir 可能指向 g_lib 里的行路径(点击进入子目录):先拷到本地,
* 避免切换后 mr_library_free 释放 g_lib,dir 变成悬垂指针。 */
char target[MR_PATH_MAX];
if (!dir || !dir[0] || strlen(dir) >= sizeof target) {
show_hint("路径无效");
return 0;
}
snprintf(target, sizeof target, "%s", dir);
int is_root = (strcmp(target, g_root_dir) == 0);
mr_library next;
memset(&next, 0, sizeof next);
int n = mr_library_scan(&next, target, MR_LIB_MAX_DEPTH);
if (n < 0) {
mr_library_free(&next);
if (is_root) {
/* 未插卡/挂载失败/根不是目录:友好空态 + 可重试,绝不弹错误。 */
mr_library_free(&g_lib);
snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target);
g_sd_missing = 1;
list_rebuild();
update_topbar();
repaint_all();
meow_log(MEOW_LOG_INFO, "library: storage root unavailable: %s (errno=%d %s)",
target, errno, strerror(errno));
return 0;
}
const char *slash = strrchr(target, '/');
const char *name = (slash && slash[1]) ? slash + 1 : target;
char msg[200];
snprintf(msg, sizeof msg, "无法读取文件夹:%s", name);
show_hint(msg);
meow_log(MEOW_LOG_WARN, "library scan failed: %s (errno=%d %s)", target,
errno, strerror(errno));
return 0;
}
mr_library_free(&g_lib);
g_lib = next;
snprintf(g_cur_dir, sizeof g_cur_dir, "%s", target);
g_sd_missing = 0;
list_rebuild();
update_topbar();
g_drag = 0;
g_dragged = 0;
repaint_all();
return 1;
}
/* 顶栏刷新:重扫当前目录(无 SD 卡时就地重试;列表由 load_dir 重建)。 */
static void refresh_current(void)
{
load_dir(g_cur_dir);
}
int library_at_root(void)
{
return strcmp(g_cur_dir, g_root_dir) == 0;
}
/* 系统返回语义:逐级上移;到浏览根(TF 卡根)返回 0 → app.c 退出 App。
* 根目录锁定:不存在越过 MR_ROOT_DIR 的导航。 */
int library_go_up(void)
{
if (library_at_root()) return 0;
char parent[MR_PATH_MAX];
if (mr_path_parent(g_cur_dir, parent, (int)sizeof parent) != 0) return 0;
/* 防御:父目录若越出浏览根(理论上不会发生),钳回根。 */
if (!mr_path_within_root(parent, g_root_dir))
snprintf(parent, sizeof parent, "%s", g_root_dir);
if (!load_dir(parent)) {
/* 父目录不可读 → 提示并停留(不越根、不丢当前列表)。 */
return 1;
}
return 1;
}
/* 点击行:目录=进入;图片=从该图开始读;档案册=打开档案册。 */
static void library_open_entry(int idx)
{
if (idx < 0 || idx >= g_lib.count) return;
const mr_library_entry *e = &g_lib.items[idx];
if (e->kind == MR_LIB_KIND_DIR) {
load_dir(e->path);
return;
}
if (e->kind == MR_LIB_KIND_IMAGE) {
/* 按文件名定位起始页:由阅读器用自己的全量目录册算下标。
* 书库列表可能被 MR_LIB_SCAN_MAX 截断,绝不能从列表推导下标(会点 A 开 B)。 */
mr_reader_open_dir_at_name(g_cur_dir, e->name);
return;
}
if (e->kind == MR_LIB_KIND_ARCHIVE) {
mr_reader_open_dir_at(e->path, MR_READER_START_RESUME);
}
}
/* ============================================================ */
/* 事件 */
/* ============================================================ */
void library_on_tap(int x, int y)
{
if (!g_ctrl.active) return;
g_drag = 0;
g_dragged = 0;
press_clear();
if (g_tb) {
int hit = meow_topappbar_hit(g_tb, x, y);
if (hit == MEOW_TOPBAR_BACK) { library_go_up(); return; }
if (g_act_refresh >= 0 && hit == g_act_refresh) { refresh_current(); return; }
if (g_act_settings >= 0 && hit == g_act_settings) {
meow_view_page_push(ACT_SETTINGS);
return;
}
}
if (!g_lv) return;
int idx = meow_listview_hit(g_lv, x, y);
if (idx < 0) return;
mr_library_row row;
if (!mr_library_row_at(&g_lib, idx, &row) || row.disabled) return;
library_open_entry(row.index);
}
void library_on_swipe(int dir, int dist)
{
if (!g_ctrl.active || !g_lv) return;
if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return;
if (g_dragged) { /* 本次手势已实时跟手滚动,抬手只补 fling */
g_dragged = 0;
g_drag = 0;
if (g_scroll) meow_scroll_fling(g_scroll);
return;
}
int del = (dir == MEOW_SWIPE_UP) ? dist : -dist;
if (del == 0) del = (dir == MEOW_SWIPE_UP) ? 120 : -120;
if (g_scroll) {
meow_scroll_track(g_scroll, del, meow_ui_time_ms());
meow_scroll_fling(g_scroll);
} else {
meow_listview_scroll_by(g_lv, del);
}
list_invalidate();
}
void library_on_touch_down(int x, int y)
{
if (!g_ctrl.active || !g_lv) return;
/* 每次按下都作废上一次手势的拖拽状态:热区/左缘提前返回时不能残留 g_drag,
* 否则系统返回手势的 move 仍会驱动列表滚动。 */
g_drag = 0;
g_dragged = 0;
press_clear();
/* 屏底 28px 是系统「上滑回桌面」热区:不从这里启动列表拖拽,避免拦截系统手势。 */
if (y >= meow_ui_height() - 28) return;
/* 左缘 24px 是系统返回手势带(与 reader 同口径):列表不启动拖拽,把滑动留给系统返回。 */
if (x < 24) return;
if (x < g_list_x || x >= g_list_x + g_list_w ||
y < g_list_y || y >= g_list_y + g_list_h) return;
g_drag = 1;
g_dragged = 0;
g_drag_last_y = y;
/* 按下高亮(与文件管理器一致;拖动超阈值即清除)。 */
g_press_row = meow_listview_hit(g_lv, x, y);
if (g_press_row >= 0) meow_listview_set_sel(g_lv, g_press_row);
}
void library_on_touch_move(int x, int y)
{
(void)x;
if (!g_ctrl.active || !g_drag || !g_lv) return;
int dy = g_drag_last_y - y;
if (dy == 0) return;
if (dy > 1 || dy < -1) g_dragged = 1;
if (g_press_row >= 0) {
g_press_delta += dy > 0 ? dy : -dy;
if (g_press_delta > 10) press_clear(); /* 拖动:清除按下高亮 */
}
g_drag_last_y = y;
if (g_scroll) meow_scroll_track(g_scroll, dy, meow_ui_time_ms());
else meow_listview_scroll_by(g_lv, dy);
list_invalidate();
}
/* 16ms tick:只剩提示横幅到期清除(列表同步扫描,无后台补位)。 */
void library_on_tick(void)
{
if (!g_ctrl.active) return;
if (g_hint[0] && meow_ui_time_ms() >= g_hint_until) {
g_hint[0] = '\0';
hint_invalidate();
}
}
/* ============================================================ */
/* view 生命周期 */
/* ============================================================ */
static void library_page_create(meow_view *v, void *ctx)
{
(void)v;
(void)ctx;
int w = meow_ui_width();
int sb = meow_ui_statusbar_height();
g_tb = meow_topappbar_create(0, sb, w, MR_TOPAPPBAR_H, "图库");
if (g_tb) {
g_act_refresh = meow_topappbar_add_action(g_tb, "");
if (g_act_refresh >= 0) meow_topappbar_set_item_icon(g_tb, g_act_refresh, MEOW_ICON_REFRESH);
g_act_settings = meow_topappbar_add_action(g_tb, "");
if (g_act_settings >= 0) meow_topappbar_set_item_icon(g_tb, g_act_settings, MEOW_ICON_SETTINGS);
}
hint_place();
g_hint_id = meow_draw_custom_register(MEOW_Z_TOP, g_hint_x, g_hint_y,
g_hint_w, g_hint_h, library_draw_hint, NULL);
load_dir(g_root_dir);
}
static void library_page_enter(meow_view *v, void *ctx)
{
((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(0); /* 图库保留状态栏(reader 退出时若未复原也在此兜底) */
meow_ui_set_page_bg(meow_theme_get()->bg);
int sb = meow_ui_statusbar_height();
if (g_tb) meow_widget_set_rect(g_tb, 0, sb, meow_ui_width(), MR_TOPAPPBAR_H);
if (g_lv) {
int y = sb + MR_TOPAPPBAR_H;
int h = meow_ui_height() - y;
if (h < 1) h = 1;
meow_widget_set_rect(g_lv, 0, y, meow_ui_width(), h);
g_list_y = y;
g_list_w = meow_ui_width();
g_list_h = h;
}
hint_place();
g_drag = 0;
g_dragged = 0;
/* 无 SD 卡空态:重进页面即重扫(用户要求的可重试路径之一)。 */
if (g_sd_missing) load_dir(g_cur_dir);
update_topbar();
mr_nav_report();
meow_ui_paint_full();
}
static void library_page_leave(meow_view *v, void *ctx)
{
(void)v;
((mr_page_ctrl *)ctx)->active = 0;
g_drag = 0;
g_dragged = 0;
press_clear();
if (g_scroll) meow_scroll_stop(g_scroll);
}
static void library_page_destroy(meow_view *v, void *ctx)
{
(void)v; (void)ctx;
if (g_hint_id) { meow_draw_unregister(g_hint_id); g_hint_id = 0; }
if (g_scroll) { meow_scroll_free(g_scroll); g_scroll = NULL; }
if (g_lv) { meow_widget_free(g_lv); g_lv = NULL; }
if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; }
g_act_refresh = g_act_settings = -1;
mr_library_free(&g_lib);
}
meow_view *library_view_create(void)
{
static const meow_view_hooks h = {
.on_create = library_page_create,
.on_enter = library_page_enter,
.on_leave = library_page_leave,
.on_destroy = library_page_destroy,
};
return meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &h, &g_ctrl);
}
#ifdef MR_LIBRARY_UI_TEST_HOOKS
/* 宿主集成测试接缝(只在 -DMR_LIBRARY_UI_TEST_HOOKS 编译时存在;不进交付二进制)。 */
int library_test_load(const char *dir)
{
return load_dir(dir);
}
void library_test_set_root(const char *root)
{
if (root && root[0]) snprintf(g_root_dir, sizeof g_root_dir, "%s", root);
}
const char *library_test_root(void)
{
return g_root_dir;
}
const char *library_test_dir(void)
{
return g_cur_dir;
}
void library_test_set_dir(const char *dir)
{
if (dir && dir[0]) snprintf(g_cur_dir, sizeof g_cur_dir, "%s", dir);
}
int library_test_rows(void)
{
return g_lib.count + (g_lib.truncated ? 1 : 0);
}
int library_test_has_list(void)
{
return g_lv != NULL;
}
int library_test_src(int idx, meow_list_item *item)
{
return lib_src(idx, item, NULL);
}
/* 直接打开第 idx 行(等价点击行体;测试里避免滚动/坐标干扰)。 */
int library_test_open_row(int idx)
{
if (idx < 0 || idx >= g_lib.count) return 0;
library_open_entry(idx);
return 1;
}
int library_test_hint_active(void)
{
return g_hint[0] != '\0';
}
const char *library_test_hint(void)
{
return g_hint;
}
/* 顶栏返回箭头是否显示(根目录必须隐藏)。 */
int library_test_topbar_back(void)
{
return g_tb_back;
}
/* 当前列表空态文案(无 SD 卡/空文件夹)。 */
const char *library_test_empty_text(void)
{
return empty_text_for_state();
}
/* 1=根目录不可访问(未检测到 SD 卡的空态)。 */
int library_test_sd_missing(void)
{
return g_sd_missing;
}
/* 顶栏刷新动作:重扫当前目录(无 SD 卡时就地重试)。 */
int library_test_retry(void)
{
refresh_current();
return library_test_rows();
}
int library_test_go_up(void)
{
return library_go_up();
}
/* 跟手拖拽是否在本次手势中被启动(左缘/屏底热区排除的回归观测口)。 */
int library_test_drag_active(void)
{
return g_drag;
}
#endif