Files
MeowOS-For-R3P2/patch_v2/reader_app/app/shelf.c
T
AskaEth 2881ce908a feat(reader): M33 目录模式逐级下钻 — 子目录/书分层显示(416 本不再平铺)
- folder 模式从「按第一级目录分段」改为当前目录视图:子目录在前(带子树书数),
  直接书在后;进入/返回只过滤已扫平的 g_books,不 readdir、不重扫(M32 索引不动)。
- 纯函数 shelf_build_rows_at(books,n,folder,cwd,...) + nav 工具(enter/up/breadcrumb/
  count)全部宿主可测;format 模式行序列/副题逐字不变,path 仍是完整路径(进度键不变)。
- 交互:点目录进入;顶栏返回箭头/左缘返回手势回上一级,根层再返回退出 App;顶栏标题=
  当前目录名(按像素宽截断),副题行=面包屑全路径+当前层书数;list/grid 均支持导航。
- 状态:folder↔format、list↔grid 切换保留 cwd;刷新重扫后 cwd 失效则收缩到有书的祖先。
- 边界:空目录不显示(导航只读 g_books,不额外 readdir);深层目录逐级进入;真实名为
  「未分类」的目录按普通目录处理,不与根层书合并。
- 测试:shelf_group_smoke 63→99 断言;M27 的按第一级分组断言改写为「逐级进入后可见该书」
  (同等强度),新增进入/返回/面包屑/计数/空目录/纯内存/rowcap/UTF-8 截断断言。
- 交付 0.1.7/code8(M32 已占用任务书里的 0.1.6/code7);真机 0.1.4 未被覆盖。
2026-11-10 19:26:44 +08:00

704 lines
33 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 语义不变:首次进书架=索引或全扫;从阅读页返回=不扫;顶栏「刷新」=清索引+全扫。
*/
#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
/* 圆角卡容器阈值:行数 ≤ 该值用 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(); }
}