diff --git a/patch_v2/reader_app/app.c b/patch_v2/reader_app/app.c index 17fdde3..a88d237 100644 --- a/patch_v2/reader_app/app.c +++ b/patch_v2/reader_app/app.c @@ -30,8 +30,14 @@ static void app_on_swipe(int dir, int dx, int dy) if (settings_is_active()) { return; } /* 设置页滑动不翻页 */ if (reader_is_active()) { reader_on_swipe(dir, dx, dy); return; } if (shelf_is_active()) { - /* 书架根页左缘右滑(返回手势)→ 退出 App(镜像 hello_app/files_app 的 LEFT→返回)。 */ - if (dir == MEOW_SWIPE_LEFT) { meow_ui_request_exit(); return; } + /* 书架根页左缘右滑(返回手势): + * folder 模式且不在 Books 根 → 回上一级目录(M33); + * 已在根 / format 模式 → 退出 App(与既有行为一致)。 */ + if (dir == MEOW_SWIPE_LEFT) { + if (shelf_back()) return; + meow_ui_request_exit(); + return; + } shelf_on_swipe(dir, dx, dy); return; } if (dir == MEOW_SWIPE_LEFT) { meow_ui_request_exit(); } diff --git a/patch_v2/reader_app/app.json b/patch_v2/reader_app/app.json index 64f132a..e143c22 100644 --- a/patch_v2/reader_app/app.json +++ b/patch_v2/reader_app/app.json @@ -1 +1 @@ -{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1.6","version_code":7,"framework":1,"fonts":"common","author":"MeowOS","icon":"icon.png","volume_page":true} +{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1.7","version_code":8,"framework":1,"fonts":"common","author":"MeowOS","icon":"icon.png","volume_page":true} diff --git a/patch_v2/reader_app/app/shelf.c b/patch_v2/reader_app/app/shelf.c index 262325c..a9889f0 100644 --- a/patch_v2/reader_app/app/shelf.c +++ b/patch_v2/reader_app/app/shelf.c @@ -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; diff --git a/patch_v2/reader_app/app/shelf_model.c b/patch_v2/reader_app/app/shelf_model.c index 64f4325..8bed98c 100644 --- a/patch_v2/reader_app/app/shelf_model.c +++ b/patch_v2/reader_app/app/shelf_model.c @@ -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); +} diff --git a/patch_v2/reader_app/app/shelf_model.h b/patch_v2/reader_app/app/shelf_model.h index 3e44ead..1f47e8d 100644 --- a/patch_v2/reader_app/app/shelf_model.h +++ b/patch_v2/reader_app/app/shelf_model.h @@ -3,14 +3,15 @@ * 从 shelf.c 拆出,供 shelf.c(控件装配)、宿主单测(test/shelf_group_smoke.c)与 * 扫描计时(test/shelf_scan_bench.c)共用。 * - * 目录归属(M27 r2):folder 模式按「Books/ 下的第一级目录」分组(如 "轻小说"、 - * "400+本高质量完本合集");更深的相对子路径("01、玄幻/01、斗破")进该书的副题, - * 不再每组一本书把书架炸成上百个段头。Books/ 根下的书 top="",显示段头 SHELF_GROUP_ROOT。 - * 分组键是「第一级目录名」,完整相对路径保留在 group[] 供副题/调试用。 - * a/b 与 a/c 同属 a 组;a/b 与 b 仍是两个组(不会因为只取末级目录名而混)。 + * 目录视图(M33):folder 模式不再是「按第一级目录分段」,而是**逐级进入**——书架只显示 + * 当前目录(cwd,相对 Books/,根="")的内容:子目录在前(每个带子树书数)、直接属于该层的 + * 书在后。cwd 由 App 维护;本模块的 shelf_build_rows_at() 只在已扫平的 books[].group 上做 + * 前缀过滤,**不读盘、不 readdir**(M32 索引省下的 readdir 不能又被导航用回去)。 + * M27 的 top(第一级目录名)仍保留在 shelf_book 里供索引/副题/诊断使用,但不再决定行序列。 + * 完整相对路径保留在 group[]:path 仍是书文件的完整绝对路径。 * - * 硬约束:path 永远是书的完整路径(打开书 + 进度键 rdprog_hash(path)),分组只写 top/group, - * 绝不改写 path;两个分组模式间切换不影响进度。 + * 硬约束:path 永远是书的完整路径(打开书 + 进度键 rdprog_hash(path)),导航只改 cwd, + * 绝不改写 path;format/folder 两个模式间切换不影响进度。 */ #ifndef SHELF_MODEL_H #define SHELF_MODEL_H @@ -27,24 +28,29 @@ #define SHELF_SCAN_MAX_DEPTH 6 /* 行数上限:每本书最多贡献 1 行书 + 1 行段头(段头数 ≤ 书数),故 2N 必够。 */ #define SHELF_MAX_ROWS (SHELF_MAX_BOOKS * 2) -#define SHELF_GROUP_ROOT "未分类" /* Books/ 根下书籍的段头 */ typedef struct shelf_book { char path[400]; /* 完整路径:打开书 + 进度键;分组不得改写 */ char title[128]; - char sub[160]; /* 默认(format/宫格)副题:格式 · 大小 [· 读],与 M27 前逐字一致 */ - char sub_folder[160]; /* folder 模式副题:更深子路径 · 格式 · 大小 [· 读] */ - char group[SHELF_GROUP_MAX]; /* 相对 Books/ 的完整目录路径(供副题);根下="" */ - char top[SHELF_GROUP_MAX]; /* 分组键:相对路径的第一级目录名;根下="" */ + char sub[160]; /* 默认副题:格式 · 大小 [· 读],与 M27 前逐字一致; + * format 模式与 folder 导航视图(书就在当前层)共用 */ + char sub_folder[160]; /* 深层子路径 · 格式 · 大小 [· 读](M33 起导航视图不再显示; + * 保留字段供测试/兼容,不要用它改写 path) */ + char group[SHELF_GROUP_MAX]; /* 相对 Books/ 的完整目录路径:folder 导航的前缀键;根下="" */ + char top[SHELF_GROUP_MAX]; /* 第一级目录名(M27 遗留字段,供索引/诊断;M33 起不决定行序列) */ int fmt; /* 0 txt,1 fb2,2 epub,3 mobi,4 cbz */ long size; uint64_t ts; /* 最近阅读 unix;无=0 */ } shelf_book; typedef struct shelf_row { - int section; /* 1=段头行 */ + int section; /* 1=段头行(仅 format 模式) */ + int dir; /* 1=目录导航行(仅 folder 模式);0=书行 */ int fmt; /* 段头/书行格式;段头无 label 时显示格式名 */ - int book; /* 书行=books 下标;段头=-1 */ + int book; /* 书行=books 下标;段头/目录行=-1 */ + int count; /* 目录行:该子目录下(含更深层)书籍数 */ + char name[SHELF_GROUP_MAX]; /* 目录行:子目录名(不含父路径) */ + char info[24]; /* 目录行:展示副题,如 "12 本" */ const char *label; /* 段头标题;NULL=格式名(指向 books[].top 或静态串) */ } shelf_row; @@ -82,12 +88,36 @@ int shelf_scan_books(const char *root, shelf_book *books, int cap, /* 生成行序列: * folder_mode=0:按格式分段(TXT/FB2/EPUB/MOBI/CBZ,空格式不出段头)——与 M27 前逐行一致; - * folder_mode=1:按 books[].top(Books/ 第一级目录)分组,根组「未分类」置顶, - * 其余组按组名排序;组内沿用 books[] 全局顺序(最近阅读优先、其次书名)。 + * folder_mode=1:当前目录 cwd(相对 Books/,根="")的导航行——子目录在前(按名排序, + * 每个带 count/info),直接属于该层的书在后(沿用 books[] 全局顺序)。 + * 纯内存:只读 books[].group,不做任何 opendir/readdir/stat。 * 返回行数;超过 rowcap 时只写 rowcap 行并返回 rowcap。 */ +int shelf_build_rows_at(const shelf_book *books, int nbooks, int folder_mode, + const char *cwd, shelf_row *rows, int rowcap); + +/* 兼容旧调用:等价于 shelf_build_rows_at(..., cwd=""),即 folder 模式显示 Books/ 根层。 */ int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode, shelf_row *rows, int rowcap); +/* ---- 目录导航(纯字符串/纯内存工具,宿主可测) ---- */ + +/* cwd 子树(含 cwd 本层书)的书籍数。cwd 无效/无书返回 0。 */ +int shelf_nav_count_under(const shelf_book *books, int nbooks, const char *cwd); + +/* 进入子目录:cwd + "/" + name → out。1=成功;0=非法/超长(out 不改)。 + * 根目录 cwd="" 时结果为 name(无前导 '/')。 */ +int shelf_nav_enter(const char *cwd, const char *name, char *out, int outsz); + +/* 返回上一级:非根返回 1 并写 out(根父为 "");已在根返回 0。 */ +int shelf_nav_up(const char *cwd, char *out, int outsz); + +/* 面包屑:根 → "Books/";子目录 → "Books/"。UTF-8 安全截断到 outsz; + * 超长时保留末尾(当前目录最重要)并加 "…" 前缀。返回写入字节数。 */ +int shelf_breadcrumb(const char *cwd, char *out, int outsz); + +/* 当前目录名(cwd 最后一级);根返回 ""。返回写入字节数。 */ +int shelf_dir_name(const char *cwd, char *out, int outsz); + /* 工具(单测直接调用)。 */ int shelf_fmt_from_ext(const char *name); const char *shelf_fmt_name(int fmt); diff --git a/patch_v2/reader_app/reader_internal.h b/patch_v2/reader_app/reader_internal.h index 0e98479..187d6a0 100644 --- a/patch_v2/reader_app/reader_internal.h +++ b/patch_v2/reader_app/reader_internal.h @@ -19,6 +19,7 @@ int shelf_is_active(void); int shelf_on_tap(int x, int y); void shelf_on_swipe(int dir, int dx, int dy); void shelf_redraw(void); +int shelf_back(void); /* M33:folder 模式返回上一级;1=已回退 0=已在根 */ void shelf_set_view(int grid); /* 1=grid 0=list;读 reader.conf ui.shelf_layout(A) */ void shelf_set_group(int folder); /* 1=folder 0=format;写 ui.shelf_group(M27) */ void shelf_on_tick(uint64_t now); /* 提示条 6s 到期消失(书架前台时由 app.c 调) */ diff --git a/patch_v2/reader_app/test/shelf_group_smoke.c b/patch_v2/reader_app/test/shelf_group_smoke.c index 1539157..869b9d2 100644 --- a/patch_v2/reader_app/test/shelf_group_smoke.c +++ b/patch_v2/reader_app/test/shelf_group_smoke.c @@ -1,18 +1,21 @@ -/* test/shelf_group_smoke.c — M27 书架目录分组宿主单测(纯 C,gcc 直接跑,不依赖设备/SD 卡)。 +/* test/shelf_group_smoke.c — M27/M33 书架目录模型宿主单测(纯 C,gcc 直接跑,不依赖设备/SD 卡)。 * * 覆盖: * G1 扫描:格式识别 / 标题去扩展名 / 目录归属(group=完整相对路径,top=第一级目录) * G2 format 模式 = 改动前行为(格式段头 TXT/FB2/EPUB/MOBI/CBZ,最近阅读优先、其次书名) - * G3 folder 模式:按 Books/ 第一级目录分段(未分类置顶,其余按组名排序), - * 更深子路径(分类/作品)进副题;a/b 与 a/c 同组 a - * G4 空目录不产生段头;depth 3/4 收录,depth > SHELF_SCAN_MAX_DEPTH 计数且不出书 - * G5 Books/ 根下的书归「未分类」;字面名为「未分类」的目录与根组是两个不同键 + * G3(M33 改写)folder 模式 = 逐级进入:根层只列子目录(排序/计数)+ 根下书; + * 进 400+ → 01、玄幻 → 01、斗破 才能看到书(不再把深层书平铺在顶级) + * G4 空目录不产生导航行;depth 3/4 收录,depth > SHELF_SCAN_MAX_DEPTH 计数且不出书 + * G5.1(M33 改写)a/b 与 a/c 合并为目录 a(2),逐级进入后可见书 1/2; + * 组件边界(a 不含 ab/c);G5.2 字面名「未分类」的目录与根层书是两个独立行 * G6 UTF-8 安全截断(中文目录名不切出半个字符) * G7 超上限不静默:truncated=1(含「cap 卡在目录边界」与「恰好装满不误报」两种); * 行数上限(段头 ≤ 书数 ⇒ 2N 必够) * G8 进度不受分组影响:path 不变、rdprog_load 前后同值、ts 两种模式一致 * G9 副题格式化的唯一实现 shelf_fmt_sub:扫描与「刚读完置顶」共用 * (ts 变化后重算 → 「· 读」立即出现;folder 子路径前缀随 group 重建) + * G10(M33)导航纯函数:虚拟目录(磁盘不存在)也能出行序列(不 readdir); + * 进出/面包屑/目录名/rowcap/UTF-8 截断 */ #include "shelf_model.h" #include "progress.h" @@ -61,14 +64,17 @@ static uint64_t ts_of(const char *path) return 0; } -/* 把行序列打成一串便于整体比对:S:<段头>|B:<书名>(<目录>)| */ +/* 把行序列打成一串便于整体比对:S:<段头>|D:<目录>(<书数>)|B:<书名>(<目录>)| */ static void dump_rows(const shelf_row *rows, int nrows, const shelf_book *books, char *out, int outsz) { int off = 0; out[0] = 0; for (int i = 0; i < nrows && off < outsz; i++) { - if (rows[i].section) { + if (rows[i].dir) { + off += snprintf(out + off, (size_t)(outsz - off), "D:%s(%d)|", + rows[i].name, rows[i].count); + } else if (rows[i].section) { const char *label = rows[i].label ? rows[i].label : shelf_fmt_name(rows[i].fmt); off += snprintf(out + off, (size_t)(outsz - off), "S:%s|", label); } else { @@ -192,19 +198,79 @@ static void test_scan_and_modes(const char *root, const char *tmproot) "format 模式段头/顺序与旧行为一致"); CHECK(nf == 11, "format 行数 = 3 段头 + 8 书"); - /* G3 folder 模式:按第一级目录分组(未分类置顶 → 400+ → B → D → F2 → kh → 轻小说) */ + /* G3(M33 改写)folder 模式 = Books 根层导航:子目录在前(排序、带子树书数), + * 根下书在后。M27 的「第一级目录段头」语义已被逐级进入取代(断言同等甚至更强: + * 不仅可见该书,还锁定了它所在的目录层级与计数)。 */ int nd = shelf_build_rows(books, n, 1, rows, 128); dump_rows(rows, nd, books, got, sizeof got); CHECK(strcmp(got, - "S:未分类|B:根下()|" - "S:400+本高质量完本合集|B:《斗破》(400+本高质量完本合集/01、玄幻/01、斗破)|" - "S:B|B:5(B/sub)|S:D|B:6(D/E/F)|S:F2|B:9(F2/a/b/c)|S:kh|B:3(kh)|" - "S:轻小说|B:1(轻小说)|B:2(轻小说)|") == 0, - "folder 模式按第一级目录分组、段头顺序正确"); - CHECK(nd == 15, "folder 行数 = 7 段头 + 8 书"); + "D:400+本高质量完本合集(1)|D:B(1)|D:D(1)|D:F2(1)|D:kh(1)|D:轻小说(2)|B:根下()|") == 0, + "folder 根层:子目录在前(排序+子树书数),根下书在后"); + CHECK(nd == 7, "folder 根层行数 = 6 目录 + 1 根下书"); - /* G4 空目录不产生段头(C 不在序列里) */ - CHECK(strstr(got, "S:C|") == NULL, "空目录 C 无段头"); + /* G4 空目录不产生导航行(C 不在序列里) */ + CHECK(strstr(got, "D:C") == NULL, "空目录 C 不出现在根层"); + + /* G3.1 逐级进入:400+ 系列(三级分类/作品/书名)不再把 1 本书平铺在顶级, + * 而是每层只看到下一层目录;最终进入作品目录才看到书。 */ + char cwd[SHELF_GROUP_MAX], next[SHELF_GROUP_MAX]; + cwd[0] = 0; + CHECK(shelf_nav_count_under(books, n, "") == 8, "根层子树共 8 本"); + CHECK(shelf_nav_count_under(books, n, "轻小说") == 2, "轻小说子树 2 本"); + CHECK(shelf_nav_count_under(books, n, "400+本高质量完本合集") == 1, "400+ 子树 1 本"); + CHECK(shelf_nav_count_under(books, n, "不存在") == 0, "不存在的目录 0 本"); + + CHECK(shelf_nav_enter("", "400+本高质量完本合集", next, sizeof next) == 1 && + strcmp(next, "400+本高质量完本合集") == 0, "从根进入 400+ 目录"); + snprintf(cwd, sizeof cwd, "%s", next); + int n1 = shelf_build_rows_at(books, n, 1, cwd, rows, 128); + dump_rows(rows, n1, books, got, sizeof got); + CHECK(strcmp(got, "D:01、玄幻(1)|") == 0, + "进 400+ → 只看到分类目录 01、玄幻(不是 1 本书平铺)"); + + snprintf(next, sizeof next, "%s", cwd); + CHECK(shelf_nav_enter(next, "01、玄幻", cwd, sizeof cwd) == 1, "进入 01、玄幻"); + int n2 = shelf_build_rows_at(books, n, 1, cwd, rows, 128); + dump_rows(rows, n2, books, got, sizeof got); + CHECK(strcmp(got, "D:01、斗破(1)|") == 0, "进 01、玄幻 → 看到作品目录 01、斗破"); + + snprintf(next, sizeof next, "%s", cwd); + CHECK(shelf_nav_enter(next, "01、斗破", cwd, sizeof cwd) == 1, "进入 01、斗破"); + int n3 = shelf_build_rows_at(books, n, 1, cwd, rows, 128); + dump_rows(rows, n3, books, got, sizeof got); + CHECK(strcmp(got, "B:《斗破》(400+本高质量完本合集/01、玄幻/01、斗破)|") == 0, + "进 01、斗破 → 看到书《斗破》(导航后可见该书)"); + CHECK(n3 == 1 && rows[0].book >= 0 && strcmp(books[rows[0].book].title, "《斗破》") == 0, + "书行指向正确的 books[] 下标(点书能打开正确路径)"); + + /* 返回逐级正确(与左缘手势/顶栏返回箭头同一函数) */ + CHECK(shelf_nav_up(cwd, next, sizeof next) == 1 && + strcmp(next, "400+本高质量完本合集/01、玄幻") == 0, "返回一级:01、斗破 → 01、玄幻"); + CHECK(shelf_nav_up(next, cwd, sizeof cwd) == 1 && + strcmp(cwd, "400+本高质量完本合集") == 0, "再返回:01、玄幻 → 400+"); + CHECK(shelf_nav_up(cwd, next, sizeof next) == 1 && next[0] == 0, "再返回:400+ → Books 根"); + CHECK(shelf_nav_up("", next, sizeof next) == 0 && next[0] == 0, "根层再返回 = 不动作(退出交给 App)"); + + /* 扁平目录直接出书;只有一层子目录没有书时仍显示该子目录 */ + int nq = shelf_build_rows_at(books, n, 1, "轻小说", rows, 128); + dump_rows(rows, nq, books, got, sizeof got); + CHECK(strcmp(got, "B:1(轻小说)|B:2(轻小说)|") == 0, "轻小说(扁平)→ 直接看到 2 本书"); + int nb = shelf_build_rows_at(books, n, 1, "B", rows, 128); + dump_rows(rows, nb, books, got, sizeof got); + CHECK(strcmp(got, "D:sub(1)|") == 0, "B 层没有书、只有子目录 sub → 仍显示 sub"); + int nbs = shelf_build_rows_at(books, n, 1, "B/sub", rows, 128); + dump_rows(rows, nbs, books, got, sizeof got); + CHECK(strcmp(got, "B:5(B/sub)|") == 0, "继续进入 B/sub → 看到书 5"); + + /* 面包屑 / 目录名文案 */ + char bc[64], dn[64]; + CHECK(shelf_breadcrumb("", bc, sizeof bc) == 6 && strcmp(bc, "Books/") == 0, + "根层面包屑 = Books/"); + CHECK(shelf_breadcrumb("轻小说", bc, sizeof bc) == 15 && + strcmp(bc, "Books/轻小说") == 0, "子目录面包屑 = Books/"); + CHECK(shelf_dir_name("", dn, sizeof dn) == 0, "根层无目录名"); + CHECK(shelf_dir_name("400+本高质量完本合集/01、玄幻", dn, sizeof dn) > 0 && + strcmp(dn, "01、玄幻") == 0, "目录名取最后一级"); /* G7 行数上限:段头 ≤ 书数,2N 足够;rowcap 小时只写 rowcap 行 */ CHECK(SHELF_MAX_ROWS >= SHELF_MAX_BOOKS * 2, "SHELF_MAX_ROWS ≥ 2×书上限"); @@ -249,7 +315,9 @@ static void test_scan_and_modes(const char *root, const char *tmproot) CHECK(shelf_scan_books(nope, small, 2, NULL, &rep3) == 0 && rep3.root_missing == 1, "根不存在时 root_missing=1"); - /* G5.1 a/b 与 a/c 必须归同一组 a(不是两个段头) */ + /* G5.1(M33 改写)a/b 与 a/c 在根层合并为同一个目录行 a(计数=2); + * 进入 a 才分别看到 b、c,进入 b/c 才看到书——旧断言「同属组 a」被换成 + * 「导航后可见该书」,并额外锁定目录计数。 */ char root3[400]; snprintf(root3, sizeof root3, "%s/mini2", tmproot); snprintf(p, sizeof p, "%s/a/b", root3); mkdirs(p); @@ -259,10 +327,19 @@ static void test_scan_and_modes(const char *root, const char *tmproot) shelf_book mb2[8]; shelf_scan_report rep5; int nm2 = shelf_scan_books(root3, mb2, 8, NULL, &rep5); - int nd2 = shelf_build_rows(mb2, nm2, 1, rows, 128); + int nd2 = shelf_build_rows_at(mb2, nm2, 1, "", rows, 128); dump_rows(rows, nd2, mb2, got, sizeof got); - CHECK(strcmp(got, "S:a|B:1(a/b)|B:2(a/c)|") == 0, "a/b 与 a/c 同组 a(一个段头)"); - CHECK(nd2 == 3, "a/b + a/c = 1 段头 + 2 书"); + CHECK(strcmp(got, "D:a(2)|") == 0, "a/b 与 a/c 在根层合并为目录 a(计数 2)"); + CHECK(nd2 == 1, "根层只有 1 行目录(不再按书平铺)"); + int nd2a = shelf_build_rows_at(mb2, nm2, 1, "a", rows, 128); + dump_rows(rows, nd2a, mb2, got, sizeof got); + CHECK(strcmp(got, "D:b(1)|D:c(1)|") == 0, "进入 a → 子目录 b、c 各 1 本"); + int nd2b = shelf_build_rows_at(mb2, nm2, 1, "a/b", rows, 128); + dump_rows(rows, nd2b, mb2, got, sizeof got); + CHECK(strcmp(got, "B:1(a/b)|") == 0, "进入 a/b → 导航后可见书 1"); + int nd2c = shelf_build_rows_at(mb2, nm2, 1, "a/c", rows, 128); + dump_rows(rows, nd2c, mb2, got, sizeof got); + CHECK(strcmp(got, "B:2(a/c)|") == 0, "进入 a/c → 导航后可见书 2"); CHECK(find_book(mb2, nm2, "1") && strstr(find_book(mb2, nm2, "1")->sub_folder, "b · TXT") != NULL, "a/b 的 folder 副题含 b"); CHECK(find_book(mb2, nm2, "2") && strstr(find_book(mb2, nm2, "2")->sub_folder, "c · TXT") != NULL, @@ -270,7 +347,24 @@ static void test_scan_and_modes(const char *root, const char *tmproot) CHECK(find_book(mb2, nm2, "1") && strcmp(find_book(mb2, nm2, "1")->sub, "TXT · 1B") == 0, "a/b 的 format 副题仍与 base 一致"); - /* G5.2 字面名「未分类」的目录与根组是两个独立键(不按显示名合并) */ + /* G5.1b 组件边界:a 的子树不包含 ab/c(a/b 与 ab/c 不能因前缀混淆) */ + shelf_book bnd[3]; + memset(bnd, 0, sizeof bnd); + snprintf(bnd[0].group, sizeof bnd[0].group, "a/b"); + snprintf(bnd[0].title, sizeof bnd[0].title, "one"); + snprintf(bnd[1].group, sizeof bnd[1].group, "ab/c"); + snprintf(bnd[1].title, sizeof bnd[1].title, "two"); + snprintf(bnd[2].group, sizeof bnd[2].group, "a"); + snprintf(bnd[2].title, sizeof bnd[2].title, "three"); + bnd[0].fmt = bnd[1].fmt = bnd[2].fmt = 0; + int nbnd = shelf_build_rows_at(bnd, 3, 1, "a", rows, 128); + dump_rows(rows, nbnd, bnd, got, sizeof got); + CHECK(strcmp(got, "D:b(1)|B:three(a)|") == 0, + "cwd=a 只含 a/b(不含 ab/c)且列出直接书 three"); + CHECK(shelf_nav_count_under(bnd, 3, "a") == 2, "count_under(a)=2(a/b + 直接书,不含 ab/c)"); + + /* G5.2(M33 改写)字面名「未分类」是真实目录,与根层书是两个不同实体: + * 根层同时出现目录行「未分类」和书 x;进入该目录看到 7(旧断言不合并 → 同等强度)。 */ char root2[400], mini[420]; snprintf(root2, sizeof root2, "%s/mini", tmproot); snprintf(mini, sizeof mini, "%s/未分类", root2); @@ -280,10 +374,16 @@ static void test_scan_and_modes(const char *root, const char *tmproot) shelf_book mb[8]; shelf_scan_report rep4; int nm = shelf_scan_books(root2, mb, 8, NULL, &rep4); - int ndm = shelf_build_rows(mb, nm, 1, rows, 128); + int ndm = shelf_build_rows_at(mb, nm, 1, "", rows, 128); dump_rows(rows, ndm, mb, got, sizeof got); - CHECK(strcmp(got, "S:未分类|B:x()|S:未分类|B:7(未分类)|") == 0, - "根组与名为「未分类」的目录不合并(两个段头)"); + CHECK(strcmp(got, "D:未分类(1)|B:x()|") == 0, + "根层书 x 与名为「未分类」的真实目录并存(不按显示名合并)"); + CHECK(ndm == 2 && rows[0].dir && strcmp(rows[0].name, "未分类") == 0 && + !rows[1].dir && rows[1].book >= 0 && strcmp(mb[rows[1].book].title, "x") == 0, + "目录行「未分类」与根层书 x 是两个独立行"); + int ndm2 = shelf_build_rows_at(mb, nm, 1, "未分类", rows, 128); + dump_rows(rows, ndm2, mb, got, sizeof got); + CHECK(strcmp(got, "B:7(未分类)|") == 0, "进入「未分类」目录 → 看到书 7(未被当特殊值吞掉)"); /* G6 UTF-8 安全截断 */ char src[200], dst[96]; @@ -341,6 +441,55 @@ static void test_fmt_sub(void) "一级目录书无子路径前缀"); } +/* G10(M33):导航纯函数——只在 books[].group 上做前缀过滤,目录在磁盘上**不存在** + * 也能出正确行序列(证明导航不引入 opendir/readdir/stat)。 */ +static void test_nav_pure(void) +{ + shelf_book fake[4]; + memset(fake, 0, sizeof fake); + snprintf(fake[0].group, sizeof fake[0].group, "ghost/a/b"); + snprintf(fake[0].title, sizeof fake[0].title, "A"); + snprintf(fake[1].group, sizeof fake[1].group, "ghost/a/c"); + snprintf(fake[1].title, sizeof fake[1].title, "B"); + snprintf(fake[2].group, sizeof fake[2].group, "ghost/d"); + snprintf(fake[2].title, sizeof fake[2].title, "C"); + snprintf(fake[3].group, sizeof fake[3].group, "ghost"); + snprintf(fake[3].title, sizeof fake[3].title, "D"); + for (int i = 0; i < 4; i++) fake[i].fmt = 0; + + shelf_row rows[16]; + char got[512]; + int n0 = shelf_build_rows_at(fake, 4, 1, "", rows, 16); + dump_rows(rows, n0, fake, got, sizeof got); + CHECK(strcmp(got, "D:ghost(4)|") == 0, "虚拟目录(磁盘不存在)也能出导航行:根层 ghost(4)"); + + int n1 = shelf_build_rows_at(fake, 4, 1, "ghost", rows, 16); + dump_rows(rows, n1, fake, got, sizeof got); + CHECK(strcmp(got, "D:a(2)|D:d(1)|B:D(ghost)|") == 0, + "ghost 层:子目录按名排序 + 直接书在后"); + + int n2 = shelf_build_rows_at(fake, 4, 1, "ghost/a", rows, 16); + dump_rows(rows, n2, fake, got, sizeof got); + CHECK(strcmp(got, "D:b(1)|D:c(1)|") == 0, "ghost/a 层:b、c 各 1 本"); + + int ncap = shelf_build_rows_at(fake, 4, 1, "ghost", rows, 2); + CHECK(ncap == 2, "rowcap=2 时只写 2 行且不越界"); + + /* 面包屑超长:UTF-8 安全、保留末尾、前缀 "…" */ + char longcwd[256], out[32]; + int k = 0; + for (int i = 0; i < 60; i++) { memcpy(longcwd + k, "轻", 3); k += 3; } + longcwd[k] = 0; + int bn = shelf_breadcrumb(longcwd, out, (int)sizeof out); + CHECK(bn > 0 && bn < (int)sizeof out && utf8_complete(out), "超长面包屑仍为合法 UTF-8 且不越界"); + CHECK(strncmp(out, "…", 3) == 0, "超长面包屑以省略号前缀保留当前目录末尾"); + + /* 进入目录超长:拒绝且不写 out */ + char tiny[8] = "keep"; + CHECK(shelf_nav_enter("abcdefgh", "ij", tiny, (int)sizeof tiny) == 0 && + strcmp(tiny, "keep") == 0, "拼接超长时 shelf_nav_enter 返回 0 且不改 out"); +} + static void test_progress_stability(const char *root) { /* G8:写一份进度,切换分组模式前后 rdprog_load 必须同值,path 不变。 */ @@ -427,6 +576,7 @@ int main(void) test_scan_and_modes(root, tmp); test_fmt_sub(); + test_nav_pure(); test_progress_stability(root); test_conf_default(tmp);