/* 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 语义不变:首次进书架=索引或全扫;从阅读页返回=不扫;顶栏「刷新」=清索引+全扫。 */ #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 #include #include #define SHELF_APPBAR_H 56 #define SHELF_LIST_X 0 #define SHELF_LIST_W 480 /* 圆角卡容器阈值:行数 ≤ 该值用 MD3 圆角卡(观感优先),超过则扁平行(滚动流畅优先)。 */ #define SHELF_ROUND_MAX_ROWS 200 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) */ 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 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 + 22; /* 副题区下 */ int list_h = 720 - list_y; if (list_h < 0) list_h = 0; 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(); } } 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) { int two = g_notice2[0] != 0; int top = two ? 610 : 620; int h = two ? 58 : 44; meow_ui_fill_rect(0, top, 480, h, g_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); } meow_ui_flush(); } int shelf_on_tap(int x, int y) { /* 顶栏动作先命中 */ 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 路由到本页时调用)。 */ void shelf_on_swipe(int dir, int dx, int dy) { (void)dir; (void)dx; if (g_nbooks <= 0) return; if (g_layout == 1 && g_grid) { meow_lazygrid_scroll_by(g_grid, dy); meow_lazygrid_draw(g_grid, meow_theme_get()); meow_ui_flush(); } else if (g_list) { meow_listview_scroll_by(g_list, dy); meow_widget_draw(g_list); meow_ui_flush(); } }