Files
MeowOS-SDK/tuku/ui/library/library.c
T
AskaEth 64336f95d9 图库: 书库改文件管理器模型 + 关闭缩略图 + 根锁定/无卡容错(M18)
用户反馈四件事一起改:
- 「目录里有一张图就被当成最终目录」→ 取消「目录含图=一本书」全部链路
  (探测/页数/封面/续读角标/懒算 worker/sr2 补位),列表 = 目录 + 图片 +
  .cbz/.zip(目录优先、组内自然序)。点目录=进入(空目录也进),点图片=
  mr_reader_open_dir_at(当前目录, 图片自然序下标),点档案册=打开档案册。
- 「文件夹浏览特别卡」→ 删除缩略图线程/磁盘 bmp 缓存/LRU/解码路径与行内
  缩略图;首帧只做一次 opendir/readdir+lstat,副标题(类型·大小)扫描期
  生成一次,行合成/滚动/点击零 IO 零格式化;去掉逐行 snap(自由滚动手感)。
  2000 项目录扫描 ~2.8ms(opendir=1, lstat=2000),行合成 2.78ms/200 万次
  (宿主实测;旧实现 2000 项 entry 含 512B cover,内存 2.7MB→1.7MB)。
- 「不要访问 SD 卡上级目录」→ 浏览根锁定 MR_ROOT_DIR:删除越根的顶栏
  「上级」/library_go_parent,根隐藏顶栏返回,左缘返回到根=退出 App。
- 「没有 SD 卡不要报错」→ 根不可访问(未插卡/挂载失败/根是文件)时友好空态
  「未检测到 SD 卡」+ 顶栏刷新/重进页面重试;子目录不可读才弹提示横幅。

集成陷阱:书库图片下标与阅读器目录册共享同一套过滤器(隐藏项/图片扩展名/
普通文件/符号链接)与 mr_natural_cmp,test_browse_lazy 直接对比
mr_dir_book_init 顺序,test_open_book_host 断言「点 img10 就开 img10」。

reader 侧 M17 遗留:mr_reader_open_dir_at 在阅读页已激活时先 pop 再 push,
走完整 leave/enter(SDK 对同 view 重复 push 是 no-op 且会压重复 activity)。

回归:run_tests.sh / run_webtoon_tests.sh 全 PASS;交叉构建零警告。
2026-11-05 20:26:40 +08:00

574 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(目录, 图片下标)),
* 点档案册=打开档案册(第 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;
/* 防御:父目录若越出浏览根(理论上不会发生),钳回根。 */
size_t rl = strlen(g_root_dir);
if (strlen(parent) < rl || strncmp(parent, g_root_dir, rl) != 0)
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) {
/* 下标由 logic 用与阅读器目录册相同的过滤器/比较器数出,保证「点 A 开 A」。 */
int start = mr_library_image_index(&g_lib, idx);
mr_reader_open_dir_at(g_cur_dir, start < 0 ? 0 : start);
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)
{
mr_page_ctrl *c = (mr_page_ctrl *)ctx;
c->view = v;
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);
}
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