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 未被覆盖。
This commit is contained in:
AskaEth committed 2026-11-10 19:26:44 +08:00
1 parent 190041cd67
commit 2881ce908a
7 files changed
+609 -141

No files matched your search

+163 -68
View File
@@ -70,8 +70,8 @@ void shelf_sort_recent(shelf_book *books, int nbooks)
}
/* 重建一本书的两种副题(扫描后与「刚读完置顶」共用同一份格式化逻辑)。
* sub = 格式 · 大小 [· 读](format 模式/宫格)
* sub_folder = 相对 book.group 第一级之后的子路径 · sub(folder 模式)
* sub = 格式 · 大小 [· 读](format 模式 / 宫格 / M33 folder 导航视图)
* sub_folder = 相对 book.group 第一级之后的子路径 · sub(M33 起导航视图不再显示,保留兼容)
*/
void shelf_fmt_sub(shelf_book *b)
{
@@ -229,29 +229,7 @@ int shelf_scan_books(const char *root, shelf_book *books, int cap,
/* ---- 行序列 ---- */
/* 分组键 = top(Books 第一级目录名)。长度截到第一个 '/',排序时忽略大小写。 */
static int top_len(const char *s)
{
const char *p = strchr(s, '/');
return p ? (int)(p - s) : (int)strlen(s);
}
static int top_cmp(const char *a, const char *b)
{
int la = top_len(a), lb = top_len(b);
if (!la != !lb) return la ? 1 : -1; /* 根组 "" 恒置顶 */
int m = la < lb ? la : lb;
int c = strncasecmp(a, b, (size_t)m);
if (c) return c;
return la - lb;
}
static int top_eq(const char *a, const char *b) { return top_cmp(a, b) == 0; }
/* 段头排序:根组(top=="")恒置顶,其余按第一级目录名不区分大小写排序。 */
static int cmp_group_key(const void *pa, const void *pb)
{
return top_cmp(*(const char *const *)pa, *(const char *const *)pb);
}
/* format 模式:按格式分段(TXT/FB2/EPUB/MOBI/CBZ,空格式不出段头)——与 M27 前逐行一致。 */
static int build_rows_format(const shelf_book *books, int nbooks,
shelf_row *rows, int rowcap)
{
@@ -265,50 +243,17 @@ static int build_rows_format(const shelf_book *books, int nbooks,
if (books[i].fmt != fmt) continue;
if (!have) {
if (nrows >= rowcap) break;
rows[nrows].section = 1; rows[nrows].fmt = fmt;
rows[nrows].book = -1; rows[nrows].label = NULL;
rows[nrows].section = 1; rows[nrows].dir = 0; rows[nrows].fmt = fmt;
rows[nrows].book = -1; rows[nrows].count = 0;
rows[nrows].name[0] = 0; rows[nrows].info[0] = 0;
rows[nrows].label = NULL;
nrows++;
have = 1;
}
if (nrows >= rowcap) break;
rows[nrows].section = 0; rows[nrows].fmt = fmt;
rows[nrows].book = i; rows[nrows].label = NULL;
nrows++;
}
}
return nrows;
}
static int build_rows_folder(const shelf_book *books, int nbooks,
shelf_row *rows, int rowcap)
{
/* 去重分组键(books[].top,""=根组)。键指向 books 内字符串,生存期覆盖 rows。 */
static const char *keys[SHELF_MAX_BOOKS];
int nkeys = 0;
for (int i = 0; i < nbooks; i++) {
const char *k = books[i].top;
int seen = 0;
for (int j = 0; j < nkeys; j++) {
if (top_eq(keys[j], k)) { seen = 1; break; }
}
if (!seen) keys[nkeys++] = k;
}
qsort(keys, (size_t)nkeys, sizeof keys[0], cmp_group_key);
int nrows = 0;
for (int ki = 0; ki < nkeys; ki++) {
if (nrows >= rowcap) break;
rows[nrows].section = 1;
rows[nrows].fmt = -1;
rows[nrows].book = -1;
rows[nrows].label = keys[ki][0] ? keys[ki] : SHELF_GROUP_ROOT;
nrows++;
for (int i = 0; i < nbooks; i++) {
if (!top_eq(books[i].top, keys[ki])) continue;
if (nrows >= rowcap) break;
rows[nrows].section = 0;
rows[nrows].fmt = books[i].fmt;
rows[nrows].book = i;
rows[nrows].section = 0; rows[nrows].dir = 0; rows[nrows].fmt = fmt;
rows[nrows].book = i; rows[nrows].count = 0;
rows[nrows].name[0] = 0; rows[nrows].info[0] = 0;
rows[nrows].label = NULL;
nrows++;
}
@@ -316,11 +261,161 @@ static int build_rows_folder(const shelf_book *books, int nbooks,
return nrows;
}
int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode,
shelf_row *rows, int rowcap)
/* group 相对 cwd 的剩余路径:group==cwd → ""(书直接在当前层);group 在 cwd 下 → 去掉
* "cwd/" 前缀的剩余部分;不在 cwd 子树内 → NULL。只做组件边界匹配(a/b 不匹配 a/bc)。 */
static const char *rel_under_cwd(const char *group, const char *cwd)
{
if (!cwd || !cwd[0]) return group;
size_t n = strlen(cwd);
if (strncmp(group, cwd, n) != 0) return NULL;
if (group[n] == '/') return group + n + 1;
if (group[n] == 0) return group + n; /* 直接属于 cwd:剩余为空串 */
return NULL; /* "a/bc" 不属于 "a/b" */
}
/* 目录行排序:子目录名不区分大小写;同名前缀再按字节序(与 M27 的组名排序口径一致)。 */
static int nav_name_cmp(const void *pa, const void *pb)
{
const shelf_row *a = (const shelf_row *)pa, *b = (const shelf_row *)pb;
int c = strcasecmp(a->name, b->name);
if (c) return c;
return strcmp(a->name, b->name);
}
/* folder 模式:当前目录视图(纯内存,只读 books[].group)。
* 步骤:①把所有「更深的书」按第一段子目录名各记一行(count 先记 1)②按名排序
* ③相邻同名合并(count 累加 = 子树书数)④追加直接属于 cwd 的书(沿用 books[] 全局顺序)。
* 目录行借用 rows[] 自身做排序/合并,不额外占内存,也不读盘。 */
static int build_rows_folder_at(const shelf_book *books, int nbooks, const char *cwd,
shelf_row *rows, int rowcap)
{
int nd = 0;
for (int i = 0; i < nbooks && nd < rowcap; i++) {
const char *rel = rel_under_cwd(books[i].group, cwd);
if (!rel || !rel[0]) continue; /* 不在本层/直接属于本层的书后处理 */
const char *slash = strchr(rel, '/');
int len = slash ? (int)(slash - rel) : (int)strlen(rel);
if (len <= 0) continue;
if (len >= (int)sizeof rows[nd].name) len = (int)sizeof rows[nd].name - 1;
shelf_row *r = &rows[nd];
memcpy(r->name, rel, (size_t)len);
r->name[len] = 0;
r->section = 0; r->dir = 1; r->fmt = -1; r->book = -1;
r->count = 1; r->info[0] = 0; r->label = NULL;
nd++;
}
qsort(rows, (size_t)nd, sizeof rows[0], nav_name_cmp);
/* 合并同名(exFAT 大小写不敏感;扫描可能把同一目录的不同拼写带进来)。 */
int w = 0;
for (int i = 0; i < nd; i++) {
if (w > 0 && strcasecmp(rows[w - 1].name, rows[i].name) == 0) {
rows[w - 1].count += rows[i].count;
} else {
if (w != i) rows[w] = rows[i];
w++;
}
}
nd = w;
for (int i = 0; i < nd; i++)
snprintf(rows[i].info, sizeof rows[i].info, "%d 本", rows[i].count);
/* 本层直接书:book 下标指回 books[],path/进度键不受影响。 */
for (int i = 0; i < nbooks && nd < rowcap; i++) {
const char *rel = rel_under_cwd(books[i].group, cwd);
if (!rel || rel[0]) continue;
shelf_row *r = &rows[nd];
r->section = 0; r->dir = 0; r->fmt = books[i].fmt; r->book = i;
r->count = 0; r->name[0] = 0; r->info[0] = 0; r->label = NULL;
nd++;
}
return nd;
}
int shelf_build_rows_at(const shelf_book *books, int nbooks, int folder_mode,
const char *cwd, shelf_row *rows, int rowcap)
{
if (!books || !rows || nbooks <= 0 || rowcap <= 0) return 0;
if (nbooks > SHELF_MAX_BOOKS) nbooks = SHELF_MAX_BOOKS;
return folder_mode ? build_rows_folder(books, nbooks, rows, rowcap)
return folder_mode ? build_rows_folder_at(books, nbooks, cwd ? cwd : "", rows, rowcap)
: build_rows_format(books, nbooks, rows, rowcap);
}
int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode,
shelf_row *rows, int rowcap)
{
return shelf_build_rows_at(books, nbooks, folder_mode, "", rows, rowcap);
}
/* ---- 目录导航工具 ---- */
int shelf_nav_count_under(const shelf_book *books, int nbooks, const char *cwd)
{
if (!books || nbooks <= 0) return 0;
if (nbooks > SHELF_MAX_BOOKS) nbooks = SHELF_MAX_BOOKS;
int n = 0;
for (int i = 0; i < nbooks; i++)
if (rel_under_cwd(books[i].group, cwd ? cwd : "")) n++;
return n;
}
int shelf_nav_enter(const char *cwd, const char *name, char *out, int outsz)
{
if (!out || outsz <= 0) return 0;
if (!name || !name[0]) return 0;
size_t lc = (cwd && cwd[0]) ? strlen(cwd) : 0;
size_t ln = strlen(name);
size_t need = lc + (lc ? 1 : 0) + ln + 1;
if (need > (size_t)outsz) return 0; /* 超长:不写 out(不产生半截路径) */
int n;
if (lc) n = snprintf(out, (size_t)outsz, "%s/%s", cwd, name);
else n = snprintf(out, (size_t)outsz, "%s", name);
return (n > 0 && n < outsz) ? 1 : 0;
}
int shelf_nav_up(const char *cwd, char *out, int outsz)
{
if (!out || outsz <= 0) return 0;
if (!cwd || !cwd[0]) { out[0] = 0; return 0; }
const char *slash = strrchr(cwd, '/');
int len = slash ? (int)(slash - cwd) : 0;
if (len >= outsz) len = outsz - 1;
memcpy(out, cwd, (size_t)len);
out[len] = 0;
return 1;
}
int shelf_breadcrumb(const char *cwd, char *out, int outsz)
{
if (!out || outsz <= 0) return 0;
char full[400];
int n;
if (cwd && cwd[0]) n = snprintf(full, sizeof full, "Books/%s", cwd);
else n = snprintf(full, sizeof full, "Books/");
if (n < 0) { out[0] = 0; return 0; }
if (n < outsz) {
memcpy(out, full, (size_t)n + 1);
return n;
}
if (outsz <= 4) { out[0] = 0; return 0; }
/* 超长:保留末尾(当前目录更重要),前缀 "…"(3 字节 UTF-8)。 */
int keep = outsz - 1 - 3;
int start = n - keep;
if (start < 0) start = 0;
while (start < n && ((unsigned char)full[start] & 0xC0) == 0x80) start++;
int len = n - start;
if (len > outsz - 1 - 3) len = outsz - 1 - 3;
memcpy(out, "…", 3);
memcpy(out + 3, full + start, (size_t)len);
out[3 + len] = 0;
return 3 + len;
}
int shelf_dir_name(const char *cwd, char *out, int outsz)
{
if (!out || outsz <= 0) return 0;
out[0] = 0;
if (!cwd || !cwd[0]) return 0;
const char *slash = strrchr(cwd, '/');
return shelf_copy_utf8(out, outsz, slash ? slash + 1 : cwd);
}