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

+216 -30
View File
@@ -10,9 +10,13 @@
* - 封面:MVP 无封面提取(engine F1);无则圆角占位(surfaceContainerHigh + 格式徽标)。
*
* M27 分组(`ui.shelf_group`,默认 format 保持旧行为):
* format = 按格式分段(TXT/FB2/EPUB/MOBI/CBZ);
* folder = 按 Books/ 下的「第一级目录」分段(根下=未分类),更深子路径(分类/作品)进副题。
* 扫描/行序列在 app/shelf_model.c(纯 C,宿主可测);本文件只做控件装配与绘制。
* 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/;从阅读页返回不再重扫
@@ -51,6 +55,8 @@ 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) */
@@ -68,6 +74,10 @@ static uint64_t g_notice_until; /* 提示到期时刻(meow_ui_time_ms;6s
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,故进度键不受分组影响)。 */
@@ -149,10 +159,24 @@ static void scan_books_first(void)
scan_books_full();
}
/* 排队行:folder 模式按目录分段,否则按格式(默认)。 */
/* 排队行:folder 模式 = 当前目录 cwd 的导航视图;否则按格式(默认)。
* 纯内存过滤 g_books,不触发扫描(M29/M32 语义不变)。 */
static void build_rows(void)
{
g_nrows = shelf_build_rows(g_books, g_nbooks, g_group_folder, g_rows, SHELF_MAX_ROWS);
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,
@@ -176,6 +200,8 @@ static void promote_just_read(void)
}
/* ---- 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)
{
@@ -190,23 +216,38 @@ static void shelf_list_bind(int idx, meow_lv_item *item, void *ctx)
item->subtitle = NULL;
item->section = 0;
if (idx < 0 || idx >= g_nrows) return;
if (g_rows[idx].section) {
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 {
} 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;
item->subtitle = g_group_folder ? g_books[bi].sub_folder : g_books[bi].sub;
/* 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,
@@ -217,13 +258,27 @@ static void shelf_list_tapcb(int idx, int op, void *ctx)
}
}
/* ---- grid 数据源(meow_lazygrid,4 列封面卡) ---- */
static int shelf_grid_count(void *ctx) { (void)ctx; return g_nbooks; }
/* ---- 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;
@@ -242,12 +297,47 @@ static void grid_title_cut(const char *src, char *out, int outsz)
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();
if (index < 0 || index >= g_nbooks) return;
const shelf_book *b = &g_books[index];
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);
@@ -255,13 +345,16 @@ static void shelf_grid_draw(int index, int x, int y, int w, int h, int state, vo
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) {
meow_gfx_fill_round(cx, cy, cw, chh, MEOW_SHAPE_SMALL, th->surfaceContainerHigh);
/* 格式徽标(居中) */
char fc[16]; int fs = 24;
snprintf(fc, sizeof fc, "%s", fmt_name(b->fmt));
meow_ui_text_center(cx + cw / 2, cy + (chh - meow_ui_text_height(fs)) / 2, fc, fs, th->primary);
/* 目录卡用 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]; grid_title_cut(b->title, t, sizeof t);
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);
@@ -270,9 +363,15 @@ static void shelf_grid_draw(int index, int x, int y, int w, int h, int state, vo
static void shelf_grid_tapcb(int idx, void *ctx)
{
(void)ctx;
if (idx < 0 || idx >= g_nbooks) return;
if (reader_open(g_books[idx].path) == 0) {
snprintf(g_just_read, sizeof g_just_read, "%s", g_books[idx].path); /* 返回时置顶用 */
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);
}
}
@@ -317,13 +416,73 @@ static void shelf_build_content(void)
(void)th;
}
/* 顶栏动作:0=布局切换 1=分组切换 2=刷新 3=设置。动作标签 = 将切换到的模式。 */
/* 进入子目录:改 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; }
g_appbar = meow_topappbar_create(0, sbh, 480, SHELF_APPBAR_H, "书架");
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 刷新 */
@@ -351,6 +510,7 @@ static void shelf_on_enter(meow_view *v, void *ctx)
* 提示条(超上限截断 / 超深目录)语义不变:索引里存了这两个值,载入时如实重建。 */
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;
@@ -411,6 +571,9 @@ void shelf_set_group(int folder)
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();
@@ -426,12 +589,20 @@ void shelf_redraw(void)
meow_ui_clear(g_bg);
if (g_appbar) meow_widget_draw(g_appbar);
/* 副题:Books/ · N 本 [· 按目录](分组只作用于列表;宫格仍是平铺封面墙) */
/* 副题:format = "Books/ · N 本"(M27 前逐字不变);
* folder = 面包屑全路径 + 当前层子树书数,如 "Books/400+…/01、玄幻 · 12 本 · 按目录"。
* 顶栏另显示当前目录名,两者配合可看出「在哪 + 下面还有多少本」。 */
if (g_nbooks > 0) {
char sub[96];
if (g_group_folder && g_layout == 0)
snprintf(sub, sizeof sub, "Books/ · %d 本 · 按目录", g_nbooks);
else snprintf(sub, sizeof sub, "Books/ · %d 本", g_nbooks);
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);
}
@@ -471,6 +642,10 @@ 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;
@@ -484,6 +659,7 @@ int shelf_on_tap(int x, int y)
* 刷新也一定重扫;扫描成功后写回新索引(写失败只丢缓存)。 */
shelf_index_remove(READER_SD_ROOT "/Books");
scan_books_full();
nav_validate_cwd(); /* M33:刷新后 cwd 目录可能已被删 → 收缩到仍有书的祖先 */
build_rows();
shelf_build_content();
shelf_redraw();
@@ -496,12 +672,22 @@ int shelf_on_tap(int x, int y)
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 (idx >= 0) shelf_redraw();
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 (idx >= 0) shelf_redraw();
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;