feat(reader): M27 书架按目录分组(ui.shelf_group=folder|format,默认 format 不变)
- 新增 shelf_model.c/.h:扫描/行序列从 shelf.c 拆出(纯 C,宿主可测);shelf_book 加 group[96](相对 Books/ 的目录路径,根下空串),path 语义/内容完全不变(进度键仍为 rdprog_hash(path)) - folder 模式:目录段头(根下=未分类),未分类置顶、其余按目录名排序;组内沿用最近阅读优先/其次书名;空目录不产生段头 - format 模式逐行保持旧行为;新增 reader.conf ui.shelf_group,缺键默认 format - 上限 256→2048(SHELF_MAX_ROWS=2N=4096,段头≤书数),超限置可读提示,不再静默截断 - 顶栏加「目录/格式」切换,设置页加「书架分组」;UTF-8 安全截断长中文目录名 - 单测 test/shelf_group_smoke.c(38 断言)+ 计时 test/shelf_scan_bench.c - build_reader.sh:链 meow_ui.a 解析冒烟 stbi_*;无 qemu 时跳过执行而非失败
This commit is contained in:
1 parent
db672cbe8f
commit
409fd1edf7
11 files changed
+800
-129
No files matched your search
@@ -44,6 +44,7 @@ static void conf_defaults(rdconf *c)
|
||||
strcpy(c->bg, "black"); /* B 定案:默认纯黑 */
|
||||
strcpy(c->theme, "auto");
|
||||
strcpy(c->shelf, "list"); /* A 定案:默认 list */
|
||||
strcpy(c->shelf_group, "format");/* M27:默认按格式,升级不改变现有用户书架 */
|
||||
c->header = 1;
|
||||
c->footer = 1;
|
||||
c->font[0] = 0;
|
||||
@@ -72,6 +73,7 @@ rdconf *rdconf_get(void)
|
||||
*eq = 0;
|
||||
const char *k = line, *v = eq + 1;
|
||||
if (!strcmp(k, "ui.shelf_layout")) { strncpy(g.shelf, v, sizeof g.shelf - 1); g.shelf[sizeof g.shelf - 1] = 0; }
|
||||
else if (!strcmp(k, "ui.shelf_group")) { strncpy(g.shelf_group, v, sizeof g.shelf_group - 1); g.shelf_group[sizeof g.shelf_group - 1] = 0; }
|
||||
else if (!strcmp(k, "rd.fs")) { int t = atoi(v); if (t >= 14 && t <= 40) g.fs = t; }
|
||||
else if (!strcmp(k, "rd.ls")) { int t = atoi(v); if (t >= 100 && t <= 200) g.ls = t; }
|
||||
else if (!strcmp(k, "rd.bg")) { strncpy(g.bg, v, sizeof g.bg - 1); g.bg[sizeof g.bg - 1] = 0; }
|
||||
@@ -84,11 +86,14 @@ rdconf *rdconf_get(void)
|
||||
}
|
||||
/* 校验合法取值(越界回默认/回退),避免脏档破坏 UI */
|
||||
if (strcmp(g.shelf, "grid") && strcmp(g.shelf, "list")) strcpy(g.shelf, "list");
|
||||
if (strcmp(g.shelf_group, "folder") && strcmp(g.shelf_group, "format"))
|
||||
strcpy(g.shelf_group, "format");
|
||||
if (strcmp(g.bg, "black") && strcmp(g.bg, "white") &&
|
||||
strcmp(g.bg, "sepia") && strcmp(g.bg, "auto")) strcpy(g.bg, "black");
|
||||
if (strcmp(g.theme, "light") && strcmp(g.theme, "dark") &&
|
||||
strcmp(g.theme, "auto")) strcpy(g.theme, "auto");
|
||||
clamp_str(g.shelf, sizeof g.shelf);
|
||||
clamp_str(g.shelf_group, sizeof g.shelf_group);
|
||||
clamp_str(g.bg, sizeof g.bg);
|
||||
clamp_str(g.theme, sizeof g.theme);
|
||||
clamp_str(g.font, sizeof g.font);
|
||||
@@ -105,6 +110,7 @@ int rdconf_persist(void)
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f) return -5;
|
||||
fprintf(f, "ui.shelf_layout=%s\n", c->shelf);
|
||||
fprintf(f, "ui.shelf_group=%s\n", c->shelf_group);
|
||||
fprintf(f, "rd.fs=%d\n", c->fs);
|
||||
fprintf(f, "rd.ls=%d\n", c->ls);
|
||||
fprintf(f, "rd.bg=%s\n", c->bg);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*
|
||||
* 键(本模块常量):
|
||||
* ui.shelf_layout = list|grid 书架默认布局(shelf.c 读)
|
||||
* ui.shelf_group = format|folder 书架分段:按格式(默认)或按目录(M27)
|
||||
* rd.fs = int (14..40) 全局默认字号(无进度书时 reader_open 用)
|
||||
* rd.ls = int (100..200) 全局默认行距
|
||||
* rd.bg = auto|black|white|sepia 阅读页底色预设(默认 black;B 定案)
|
||||
@@ -28,6 +29,7 @@ typedef struct rdconf {
|
||||
char bg[8]; /* auto|black|white|sepia */
|
||||
char theme[8]; /* auto|light|dark */
|
||||
char shelf[8]; /* list|grid */
|
||||
char shelf_group[10]; /* format|folder(默认 format=旧行为) */
|
||||
int header; /* 1=阅读页顶条 */
|
||||
int footer; /* 1=阅读页底条 */
|
||||
char font[400]; /* 字体路径(空=自动) */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* app/settings.c — 全局设置页(C,书架顶栏「设置」入口 → 独立页 ACT_SETTINGS)。
|
||||
*
|
||||
* 配置项(MVP,见 READER_UI_DESIGN §4.3):默认字号/默认行距/默认背景(黑白米黄)/主题(亮暗跟随
|
||||
* 全局)/书架布局(list-grid)/页头条/页底条。全部存 App 私有 reader.conf(rdconf),不进系统
|
||||
* 全局)/书架布局(list-grid)/书架分组(格式-目录)/页头条/页底条。全部存 App 私有 reader.conf(rdconf),不进系统
|
||||
* settings.conf。UI:meow_topappbar(返回+标题) + meow_listview 行(每行 label + 当前值尾部箭头,
|
||||
* 点行开 meow_dialog(SELECT) 单选;值即改即存)。
|
||||
*/
|
||||
@@ -23,13 +23,14 @@ static int g_active;
|
||||
/* 行项枚举(顺序 = 列表顺序)。ITEM_BASE = 固定的全局项数;重解析本书章节仅在
|
||||
* 已打开一本「支持章节解析」(TXT+seg 模式)的书时追加(见 settings_on_enter)。 */
|
||||
enum {
|
||||
ITEM_FS = 0, ITEM_LS, ITEM_BG, ITEM_THEME, ITEM_SHELF, ITEM_HDR, ITEM_FTR,
|
||||
ITEM_FS = 0, ITEM_LS, ITEM_BG, ITEM_THEME, ITEM_SHELF, ITEM_GROUP, ITEM_HDR, ITEM_FTR,
|
||||
ITEM_REPARSE, ITEM_COUNT
|
||||
};
|
||||
#define ITEM_BASE ITEM_REPARSE /* 7:不含重解析的固定项数 */
|
||||
#define ITEM_BASE ITEM_REPARSE /* 8:不含重解析的固定项数 */
|
||||
static int g_nitems = ITEM_BASE;
|
||||
static const char *const g_labels[ITEM_COUNT] = {
|
||||
"默认字号", "默认行距", "默认背景", "主题", "书架布局", "页头条", "页底条", "重解析本书章节"
|
||||
"默认字号", "默认行距", "默认背景", "主题", "书架布局", "书架分组",
|
||||
"页头条", "页底条", "重解析本书章节"
|
||||
};
|
||||
static void settings_redraw(void);
|
||||
|
||||
@@ -53,6 +54,11 @@ static const char *shelf_label(const rdconf *c)
|
||||
if (!c) return "列表";
|
||||
return (!strcmp(c->shelf, "grid")) ? "宫格" : "列表";
|
||||
}
|
||||
static const char *group_label(const rdconf *c)
|
||||
{
|
||||
if (!c) return "格式";
|
||||
return (!strcmp(c->shelf_group, "folder")) ? "目录" : "格式";
|
||||
}
|
||||
|
||||
/* 当前行值(subtitle)。 */
|
||||
static void item_value(int item, char *out, int outsz)
|
||||
@@ -65,6 +71,7 @@ static void item_value(int item, char *out, int outsz)
|
||||
case ITEM_BG: snprintf(out, (size_t)outsz, "%s", bg_label(c)); break;
|
||||
case ITEM_THEME: snprintf(out, (size_t)outsz, "%s", theme_label(c)); break;
|
||||
case ITEM_SHELF: snprintf(out, (size_t)outsz, "%s", shelf_label(c)); break;
|
||||
case ITEM_GROUP: snprintf(out, (size_t)outsz, "%s", group_label(c)); break;
|
||||
case ITEM_HDR: snprintf(out, (size_t)outsz, "%s", c->header ? "显示" : "隐藏"); break;
|
||||
case ITEM_FTR: snprintf(out, (size_t)outsz, "%s", c->footer ? "显示" : "隐藏"); break;
|
||||
case ITEM_REPARSE: snprintf(out, (size_t)outsz, "%s", "点按重新扫描章节"); break;
|
||||
@@ -105,6 +112,10 @@ static int idx_for(int item, const char *const *opts, int n, const rdconf *c)
|
||||
for (int i = 0; i < n; i++) if (!strcmp(opts[i], shelf_label(c))) return i;
|
||||
return 0;
|
||||
}
|
||||
if (item == ITEM_GROUP) {
|
||||
for (int i = 0; i < n; i++) if (!strcmp(opts[i], group_label(c))) return i;
|
||||
return 0;
|
||||
}
|
||||
if (item == ITEM_HDR) return c->header ? 0 : 1;
|
||||
if (item == ITEM_FTR) return c->footer ? 0 : 1;
|
||||
return 0;
|
||||
@@ -144,6 +155,9 @@ static void apply_choice(int item, int sel)
|
||||
case ITEM_SHELF:
|
||||
shelf_set_view(sel ? 1 : 0); /* 写 rdconf + 重建书架 */
|
||||
return;
|
||||
case ITEM_GROUP:
|
||||
shelf_set_group(sel ? 1 : 0); /* 写 ui.shelf_group + 重建书架(不改 path) */
|
||||
return;
|
||||
case ITEM_HDR: c->header = sel ? 0 : 1; break; /* 0 显示 1 隐藏 */
|
||||
case ITEM_FTR: c->footer = sel ? 0 : 1; break;
|
||||
default: return;
|
||||
@@ -175,6 +189,9 @@ static void settings_open_choice(int item)
|
||||
} else if (item == ITEM_SHELF) {
|
||||
static const char *s[2] = { "列表", "宫格" };
|
||||
for (int i = 0; i < 2; i++) opts[n++] = s[i];
|
||||
} else if (item == ITEM_GROUP) {
|
||||
static const char *g[2] = { "格式", "目录" };
|
||||
for (int i = 0; i < 2; i++) opts[n++] = g[i];
|
||||
} else if (item == ITEM_HDR || item == ITEM_FTR) {
|
||||
static const char *h[2] = { "显示", "隐藏" };
|
||||
for (int i = 0; i < 2; i++) opts[n++] = h[i];
|
||||
|
||||
+65
-119
@@ -4,151 +4,73 @@
|
||||
* 布局(README §3.2,用户 2026-09-04 拍板):
|
||||
* - 默认 list(meow_listview_set_container(on) MD3 圆角卡),可切 grid(meow_lazygrid 4 列封面卡);
|
||||
* 布局存 reader.conf `ui.shelf_layout`(rdconf_get()->shelf);
|
||||
* - 顶栏 meow_topappbar:标题「书架」+ 动作(布局切换 / 刷新 / App 设置入口);
|
||||
* - 顶栏 meow_topappbar:标题「书架」+ 动作(布局切换 / 分组切换 / 刷新 / App 设置入口);
|
||||
* - 无书也渲染完整骨架(顶栏 + 内容区 + 居中空态卡 + 指引 + 支持格式);
|
||||
* - 封面:MVP 无封面提取(engine F1);无则圆角占位(surfaceContainerHigh + 格式徽标)。
|
||||
*
|
||||
* M27 分组(`ui.shelf_group`,默认 format 保持旧行为):
|
||||
* format = 按格式分段(TXT/FB2/EPUB/MOBI/CBZ),folder = 按 Books/ 下的目录分段(根下=未分类)。
|
||||
* 扫描/行序列在 app/shelf_model.c(纯 C,宿主可测);本文件只做控件装配与绘制。
|
||||
*/
|
||||
#include "../reader_internal.h"
|
||||
#include "progress.h"
|
||||
#include "reader_conf.h"
|
||||
#include "shelf_model.h"
|
||||
#include "meow_ui_internal.h" /* meow_gfx_fill_round(宫格卡圆角填充) */
|
||||
#include <dirent.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#define SHELF_APPBAR_H 56
|
||||
#define SHELF_LIST_X 0
|
||||
#define SHELF_LIST_W 480
|
||||
|
||||
typedef struct shelf_book {
|
||||
char path[400];
|
||||
char title[128];
|
||||
char sub[160];
|
||||
int fmt; /* 0 txt,1 fb2,2 epub,3 mobi,4 cbz */
|
||||
long size;
|
||||
uint64_t ts; /* 最近阅读 unix;无=0 */
|
||||
} shelf_book;
|
||||
|
||||
static shelf_book g_books[256];
|
||||
static shelf_book g_books[SHELF_MAX_BOOKS];
|
||||
static int g_nbooks;
|
||||
static int g_active;
|
||||
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]; /* 提示(opendir 失败等) */
|
||||
static char g_notice[160]; /* 提示(opendir 失败/超上限截断等) */
|
||||
static int g_notice_ms; /* 剩余显示 ms(0=无) */
|
||||
|
||||
/* 段头行(list 模式):把扫描结果排队为 [段头(fmt), 书, 书, ..., 段头, ...]。
|
||||
* 书量小用段头(README §3.2),格式分组(TXT/FB2/EPUB/MOBI/CBZ)。 */
|
||||
#define SHELF_MAX_ROWS 520
|
||||
typedef struct shelf_row { char section; int fmt; int book; } shelf_row;
|
||||
/* 段头行(list 模式):[段头, 书, 书, ..., 段头, ...];SHELF_MAX_ROWS=2×书上限(见 shelf_model.h)。 */
|
||||
static shelf_row g_rows[SHELF_MAX_ROWS];
|
||||
static int g_nrows;
|
||||
|
||||
static const char *fmt_name(int f)
|
||||
{
|
||||
switch (f) { case 0: return "TXT"; case 1: return "FB2"; case 2: return "EPUB";
|
||||
case 3: return "MOBI"; case 4: return "CBZ"; default: return "?"; }
|
||||
}
|
||||
static int fmt_from_ext(const char *name)
|
||||
{
|
||||
const char *d = strrchr(name, '.');
|
||||
if (!d) return -1;
|
||||
if (strcasecmp(d, ".txt") == 0) return 0;
|
||||
if (strcasecmp(d, ".fb2") == 0) return 1;
|
||||
if (strcasecmp(d, ".epub") == 0) return 2;
|
||||
if (strcasecmp(d, ".mobi") == 0) return 3;
|
||||
if (strcasecmp(d, ".cbz") == 0) return 4;
|
||||
return -1;
|
||||
}
|
||||
static void fmt_size(char *out, int outsz, long s)
|
||||
{
|
||||
if (s >= 1 << 20) snprintf(out, outsz, "%.1fMB", (double)s / (1 << 20));
|
||||
else if (s >= 1 << 10) snprintf(out, outsz, "%.0fKB", (double)s / (1 << 10));
|
||||
else snprintf(out, outsz, "%ldB", s);
|
||||
}
|
||||
static const char *fmt_name(int f) { return shelf_fmt_name(f); }
|
||||
|
||||
static int cmp_recent_then_name(const void *a, const void *b)
|
||||
/* 进度时间戳(分组/排序用;不改 path,故进度键不受分组影响)。 */
|
||||
static uint64_t shelf_ts_of(const char *path)
|
||||
{
|
||||
const shelf_book *x = (const shelf_book *)a, *y = (const shelf_book *)b;
|
||||
if (x->ts != y->ts) return (x->ts < y->ts) ? 1 : -1; /* 最近在前 */
|
||||
return strcasecmp(x->title, y->title);
|
||||
}
|
||||
|
||||
/* 递归收集 Books/ 下(含子目录,深度 ≤2)的书。只在 scan_books 里调用。 */
|
||||
static void scan_dir(const char *dir, int depth)
|
||||
{
|
||||
if (depth > 2) return; /* 防深嵌套/循环 */
|
||||
DIR *dp = opendir(dir);
|
||||
if (!dp) return;
|
||||
struct dirent *de;
|
||||
while ((de = readdir(dp)) && g_nbooks < 256) {
|
||||
if (de->d_name[0] == '.') continue;
|
||||
char path[256];
|
||||
if (snprintf(path, sizeof path, "%s/%s", dir, de->d_name) >= (int)sizeof path) continue;
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) continue;
|
||||
if (S_ISDIR(st.st_mode)) { scan_dir(path, depth + 1); continue; }
|
||||
int f = fmt_from_ext(de->d_name);
|
||||
if (f < 0) continue;
|
||||
shelf_book *b = &g_books[g_nbooks];
|
||||
snprintf(b->path, sizeof b->path, "%s", path);
|
||||
strncpy(b->title, de->d_name, sizeof b->title - 1); b->title[sizeof b->title - 1] = 0;
|
||||
char *dot = strrchr(b->title, '.');
|
||||
if (dot) *dot = 0; /* 标题去扩展名 */
|
||||
b->fmt = f;
|
||||
b->size = (long)st.st_size;
|
||||
rdprog p;
|
||||
b->ts = (rdprog_load(b->path, &p) == 1) ? p.ts : 0;
|
||||
char sz[24]; fmt_size(sz, sizeof sz, b->size);
|
||||
snprintf(b->sub, sizeof b->sub, "%s · %s%s", fmt_name(f), sz,
|
||||
b->ts ? " · 读" : "");
|
||||
g_nbooks++;
|
||||
}
|
||||
closedir(dp);
|
||||
rdprog p;
|
||||
return (rdprog_load(path, &p) == 1) ? p.ts : 0;
|
||||
}
|
||||
|
||||
static void scan_books(void)
|
||||
{
|
||||
const char *dir = READER_SD_ROOT "/Books";
|
||||
g_nbooks = 0;
|
||||
DIR *dp = opendir(dir);
|
||||
if (!dp) {
|
||||
g_notice[0] = 0;
|
||||
g_notice_ms = 0;
|
||||
shelf_scan_report rep;
|
||||
g_nbooks = shelf_scan_books(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep);
|
||||
if (rep.root_missing) {
|
||||
snprintf(g_notice, sizeof g_notice, "未找到 %s 目录,请放入书籍(TXT)", dir);
|
||||
g_notice_ms = 4000;
|
||||
return;
|
||||
} else if (rep.truncated) {
|
||||
snprintf(g_notice, sizeof g_notice, "书籍超过 %d 本,已只显示前 %d 本",
|
||||
SHELF_MAX_BOOKS, SHELF_MAX_BOOKS);
|
||||
g_notice_ms = 4000;
|
||||
}
|
||||
closedir(dp);
|
||||
scan_dir(dir, 0); /* 递归:含 Books/<子目录>/ 下书籍(用户可能按目录组织) */
|
||||
qsort(g_books, (size_t)g_nbooks, sizeof(shelf_book), cmp_recent_then_name);
|
||||
}
|
||||
|
||||
/* 排队段头(按格式):为 list 模式生成 [段头(fmt) | 书] 序列。 */
|
||||
/* 排队行:folder 模式按目录分段,否则按格式(默认)。 */
|
||||
static void build_rows(void)
|
||||
{
|
||||
g_nrows = 0;
|
||||
static const int fmts[] = { 0, 1, 2, 3, 4 };
|
||||
for (size_t fi = 0; fi < sizeof fmts / sizeof fmts[0]; fi++) {
|
||||
int fmt = fmts[fi];
|
||||
int have = 0;
|
||||
if (g_nrows >= SHELF_MAX_ROWS) break;
|
||||
for (int i = 0; i < g_nbooks; i++) {
|
||||
if (g_books[i].fmt != fmt) continue;
|
||||
if (!have) {
|
||||
if (g_nrows >= SHELF_MAX_ROWS) break;
|
||||
g_rows[g_nrows].section = 1; g_rows[g_nrows].fmt = fmt; g_rows[g_nrows].book = -1;
|
||||
g_nrows++;
|
||||
have = 1;
|
||||
}
|
||||
if (g_nrows >= SHELF_MAX_ROWS) break;
|
||||
g_rows[g_nrows].section = 0; g_rows[g_nrows].fmt = fmt; g_rows[g_nrows].book = i;
|
||||
g_nrows++;
|
||||
}
|
||||
}
|
||||
g_nrows = shelf_build_rows(g_books, g_nbooks, g_group_folder, g_rows, SHELF_MAX_ROWS);
|
||||
}
|
||||
|
||||
/* ---- list 数据源(vsource + 段头;container on) ---- */
|
||||
@@ -168,7 +90,7 @@ static void shelf_list_bind(int idx, meow_lv_item *item, void *ctx)
|
||||
if (idx < 0 || idx >= g_nrows) return;
|
||||
if (g_rows[idx].section) {
|
||||
item->section = 1;
|
||||
item->title = fmt_name(g_rows[idx].fmt);
|
||||
item->title = g_rows[idx].label ? g_rows[idx].label : fmt_name(g_rows[idx].fmt);
|
||||
item->enabled = 0;
|
||||
} else {
|
||||
int bi = g_rows[idx].book;
|
||||
@@ -283,18 +205,19 @@ static void shelf_build_content(void)
|
||||
(void)th;
|
||||
}
|
||||
|
||||
/* 顶栏动作:0=布局切换 1=刷新 2=设置。布局切换动作标签 = 将切换到的布局。 */
|
||||
/* 顶栏动作:0=布局切换 1=分组切换 2=刷新 3=设置。动作标签 = 将切换到的模式。 */
|
||||
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, "书架");
|
||||
if (!g_appbar) return;
|
||||
meow_topappbar_add_action(g_appbar, g_layout ? "列表" : "宫格"); /* 0 布局切换 */
|
||||
meow_topappbar_add_action(g_appbar, "刷新"); /* 1 刷新 */
|
||||
meow_topappbar_add_action(g_appbar, "设置"); /* 2 设置 */
|
||||
meow_topappbar_set_item_icon(g_appbar, 1, MEOW_ICON_REFRESH);
|
||||
meow_topappbar_set_item_icon(g_appbar, 2, MEOW_ICON_SETTINGS);
|
||||
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 生命周期 */
|
||||
@@ -303,15 +226,15 @@ 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;
|
||||
scan_books(); /* 回到书架即重扫(新增/最近读置顶刷新) */
|
||||
build_rows();
|
||||
const meow_theme *th = meow_theme_get();
|
||||
g_bg = th->bg; g_fg = th->fg; g_dim = th->dim;
|
||||
|
||||
/* 布局:读 reader.conf ui.shelf_layout(A) */
|
||||
const rdconf *c = rdconf_get();
|
||||
g_layout = (c && c->shelf[0] && strcmp(c->shelf, "grid") == 0) ? 1 : 0;
|
||||
|
||||
shelf_appbar_build();
|
||||
shelf_build_content();
|
||||
shelf_redraw();
|
||||
@@ -349,6 +272,23 @@ void shelf_set_view(int grid)
|
||||
}
|
||||
}
|
||||
|
||||
/* 切换 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;
|
||||
if (g_active) {
|
||||
build_rows();
|
||||
shelf_appbar_build();
|
||||
shelf_build_content();
|
||||
shelf_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
void shelf_redraw(void)
|
||||
{
|
||||
const meow_theme *th = meow_theme_get();
|
||||
@@ -356,9 +296,12 @@ void shelf_redraw(void)
|
||||
meow_ui_clear(g_bg);
|
||||
if (g_appbar) meow_widget_draw(g_appbar);
|
||||
|
||||
/* 副题:Books/ · N 本(有书时显示格式说明;无书并入空态卡) */
|
||||
/* 副题:Books/ · N 本 [· 按目录](分组只作用于列表;宫格仍是平铺封面墙) */
|
||||
if (g_nbooks > 0) {
|
||||
char sub[96]; snprintf(sub, sizeof sub, "Books/ · %d 本", g_nbooks);
|
||||
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);
|
||||
int sbh = meow_ui_statusbar_height();
|
||||
meow_ui_text(24, sbh + SHELF_APPBAR_H + 3, sub, 16, g_dim);
|
||||
}
|
||||
@@ -397,12 +340,15 @@ int shelf_on_tap(int x, int y)
|
||||
if (bi == 0) { /* 布局切换 */
|
||||
shelf_set_view(g_layout ? 0 : 1);
|
||||
return 1;
|
||||
} else if (bi == 1) { /* 刷新:重扫 */
|
||||
} else if (bi == 1) { /* 分组切换:format ↔ folder */
|
||||
shelf_set_group(g_group_folder ? 0 : 1);
|
||||
return 1;
|
||||
} else if (bi == 2) { /* 刷新:重扫 */
|
||||
scan_books(); build_rows();
|
||||
shelf_build_content();
|
||||
shelf_redraw();
|
||||
return 1;
|
||||
} else if (bi == 2) { /* App 设置入口 */
|
||||
} else if (bi == 3) { /* App 设置入口 */
|
||||
meow_view_page_push(ACT_SETTINGS);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/* app/shelf_model.c — 书架数据模型实现(见 app/shelf_model.h)。 */
|
||||
#include "shelf_model.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#define SHELF_SCAN_MAX_DEPTH 2 /* 子目录深度上限(Books/a/b 有书,Books/a/b/c 不扫) */
|
||||
|
||||
int shelf_fmt_from_ext(const char *name)
|
||||
{
|
||||
const char *d = strrchr(name, '.');
|
||||
if (!d) return -1;
|
||||
if (strcasecmp(d, ".txt") == 0) return 0;
|
||||
if (strcasecmp(d, ".fb2") == 0) return 1;
|
||||
if (strcasecmp(d, ".epub") == 0) return 2;
|
||||
if (strcasecmp(d, ".mobi") == 0) return 3;
|
||||
if (strcasecmp(d, ".cbz") == 0) return 4;
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char *shelf_fmt_name(int f)
|
||||
{
|
||||
switch (f) {
|
||||
case 0: return "TXT";
|
||||
case 1: return "FB2";
|
||||
case 2: return "EPUB";
|
||||
case 3: return "MOBI";
|
||||
case 4: return "CBZ";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
void shelf_fmt_size(char *out, int outsz, long size)
|
||||
{
|
||||
if (!out || outsz <= 0) return;
|
||||
if (size >= 1 << 20) snprintf(out, (size_t)outsz, "%.1fMB", (double)size / (1 << 20));
|
||||
else if (size >= 1 << 10) snprintf(out, (size_t)outsz, "%.0fKB", (double)size / (1 << 10));
|
||||
else snprintf(out, (size_t)outsz, "%ldB", size);
|
||||
}
|
||||
|
||||
int shelf_copy_utf8(char *dst, int cap, const char *src)
|
||||
{
|
||||
if (!dst || cap <= 0) return 0;
|
||||
if (!src) { dst[0] = 0; return 0; }
|
||||
int limit = cap - 1;
|
||||
int n = 0;
|
||||
while (src[n] && n < limit) n++;
|
||||
if (src[n]) {
|
||||
/* 截断:若 src[n] 是续字节(10xxxxxx),回退到该字符首字节之前,整段丢弃。 */
|
||||
while (n > 0 && ((unsigned char)src[n] & 0xC0) == 0x80) n--;
|
||||
}
|
||||
memcpy(dst, src, (size_t)n);
|
||||
dst[n] = 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
static int cmp_recent_then_name(const void *a, const void *b)
|
||||
{
|
||||
const shelf_book *x = (const shelf_book *)a, *y = (const shelf_book *)b;
|
||||
if (x->ts != y->ts) return (x->ts < y->ts) ? 1 : -1; /* 最近在前 */
|
||||
return strcasecmp(x->title, y->title);
|
||||
}
|
||||
|
||||
typedef struct scan_ctx {
|
||||
shelf_book *books;
|
||||
int cap;
|
||||
int n;
|
||||
int truncated;
|
||||
shelf_ts_fn ts_fn;
|
||||
} scan_ctx;
|
||||
|
||||
/* 递归收集:rel = 相对 root 的目录路径(root 自身为 "")。 */
|
||||
static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel)
|
||||
{
|
||||
if (depth > SHELF_SCAN_MAX_DEPTH) return; /* 防深嵌套/循环 */
|
||||
if (c->n >= c->cap) return; /* 已满:不再进新目录 */
|
||||
DIR *dp = opendir(dir);
|
||||
if (!dp) return;
|
||||
struct dirent *de;
|
||||
while ((de = readdir(dp))) {
|
||||
if (de->d_name[0] == '.') continue;
|
||||
char path[400];
|
||||
if (snprintf(path, sizeof path, "%s/%s", dir, de->d_name) >= (int)sizeof path) continue;
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) continue;
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
char subrel[SHELF_GROUP_MAX * 4];
|
||||
if (snprintf(subrel, sizeof subrel, "%s%s%s", rel, rel[0] ? "/" : "", de->d_name)
|
||||
>= (int)sizeof subrel) continue;
|
||||
scan_dir(c, path, depth + 1, subrel);
|
||||
continue;
|
||||
}
|
||||
int f = shelf_fmt_from_ext(de->d_name);
|
||||
if (f < 0) continue;
|
||||
if (c->n >= c->cap) { c->truncated = 1; break; } /* 超限:可见提示,不静默 */
|
||||
shelf_book *b = &c->books[c->n];
|
||||
shelf_copy_utf8(b->path, (int)sizeof b->path, path);
|
||||
shelf_copy_utf8(b->title, (int)sizeof b->title, de->d_name);
|
||||
char *dot = strrchr(b->title, '.');
|
||||
if (dot) *dot = 0; /* 标题去扩展名 */
|
||||
shelf_copy_utf8(b->group, (int)sizeof b->group, rel);
|
||||
b->fmt = f;
|
||||
b->size = (long)st.st_size;
|
||||
b->ts = c->ts_fn ? c->ts_fn(b->path) : 0;
|
||||
char sz[24];
|
||||
shelf_fmt_size(sz, sizeof sz, b->size);
|
||||
snprintf(b->sub, sizeof b->sub, "%s · %s%s", shelf_fmt_name(f), sz,
|
||||
b->ts ? " · 读" : "");
|
||||
c->n++;
|
||||
}
|
||||
closedir(dp);
|
||||
}
|
||||
|
||||
int shelf_scan_books(const char *root, shelf_book *books, int cap,
|
||||
shelf_ts_fn ts_fn, shelf_scan_report *rep)
|
||||
{
|
||||
if (rep) { rep->nbooks = 0; rep->truncated = 0; rep->root_missing = 0; }
|
||||
if (!root || !books || cap <= 0) {
|
||||
if (rep) rep->root_missing = 1;
|
||||
return 0;
|
||||
}
|
||||
DIR *dp = opendir(root);
|
||||
if (!dp) {
|
||||
if (rep) rep->root_missing = 1;
|
||||
return 0;
|
||||
}
|
||||
closedir(dp);
|
||||
|
||||
scan_ctx c;
|
||||
c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.ts_fn = ts_fn;
|
||||
scan_dir(&c, root, 0, "");
|
||||
qsort(books, (size_t)c.n, sizeof(shelf_book), cmp_recent_then_name);
|
||||
if (rep) { rep->nbooks = c.n; rep->truncated = c.truncated; }
|
||||
return c.n;
|
||||
}
|
||||
|
||||
/* ---- 行序列 ---- */
|
||||
|
||||
/* 段头排序:根组(group=="")恒置顶,其余按目录名不区分大小写排序。 */
|
||||
static int cmp_group_key(const void *pa, const void *pb)
|
||||
{
|
||||
const char *a = *(const char *const *)pa;
|
||||
const char *b = *(const char *const *)pb;
|
||||
if (!a[0] != !b[0]) return a[0] ? 1 : -1; /* "" 在前 */
|
||||
return strcasecmp(a, b);
|
||||
}
|
||||
|
||||
static int build_rows_format(const shelf_book *books, int nbooks,
|
||||
shelf_row *rows, int rowcap)
|
||||
{
|
||||
static const int fmts[] = { 0, 1, 2, 3, 4 };
|
||||
int nrows = 0;
|
||||
for (size_t fi = 0; fi < sizeof fmts / sizeof fmts[0]; fi++) {
|
||||
int fmt = fmts[fi];
|
||||
int have = 0;
|
||||
if (nrows >= rowcap) break;
|
||||
for (int i = 0; i < nbooks; i++) {
|
||||
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;
|
||||
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[].group 的字符串,books 生存期覆盖 rows。 */
|
||||
static const char *keys[SHELF_MAX_BOOKS];
|
||||
int nkeys = 0;
|
||||
for (int i = 0; i < nbooks; i++) {
|
||||
const char *k = books[i].group;
|
||||
int seen = 0;
|
||||
for (int j = 0; j < nkeys; j++) {
|
||||
if (strcmp(keys[j], k) == 0) { 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 (strcmp(books[i].group, keys[ki]) != 0) continue;
|
||||
if (nrows >= rowcap) break;
|
||||
rows[nrows].section = 0;
|
||||
rows[nrows].fmt = books[i].fmt;
|
||||
rows[nrows].book = i;
|
||||
rows[nrows].label = NULL;
|
||||
nrows++;
|
||||
}
|
||||
}
|
||||
return nrows;
|
||||
}
|
||||
|
||||
int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode,
|
||||
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)
|
||||
: build_rows_format(books, nbooks, rows, rowcap);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* app/shelf_model.h — 书架数据模型:扫描 Books/ + 生成行序列(纯 C,不依赖 SDK/设备)。
|
||||
*
|
||||
* 从 shelf.c 拆出,供 shelf.c(控件装配)、宿主单测(test/shelf_group_smoke.c)与
|
||||
* 扫描计时(test/shelf_scan_bench.c)共用。
|
||||
*
|
||||
* 目录归属(M27):folder 模式按「相对 Books/ 的目录路径」分组,如 "轻小说"、"科幻/长篇";
|
||||
* Books/ 根下的书 group="",显示段头 SHELF_GROUP_ROOT。分组键是相对路径(不是目录名),
|
||||
* 故 Books/a/b 与 Books/b 不会混。
|
||||
*
|
||||
* 硬约束:path 永远是书的完整路径(打开书 + 进度键 rdprog_hash(path)),分组只写 group,
|
||||
* 绝不改写 path;两个分组模式间切换不影响进度。
|
||||
*/
|
||||
#ifndef SHELF_MODEL_H
|
||||
#define SHELF_MODEL_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* 书籍上限:真实场景 180 本(Books/轻小说),2048 给 11× 余量。
|
||||
* 超限不再静默停扫:report->truncated=1,由 UI 出提示。 */
|
||||
#define SHELF_MAX_BOOKS 2048
|
||||
#define SHELF_GROUP_MAX 96 /* 目录字段容量(含 NUL):95 字节 ≈ 31 个汉字 */
|
||||
/* 行数上限:每本书最多贡献 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];
|
||||
char group[SHELF_GROUP_MAX]; /* 相对 Books/ 的目录路径;根下="" */
|
||||
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 fmt; /* 段头/书行格式;段头无 label 时显示格式名 */
|
||||
int book; /* 书行=books 下标;段头=-1 */
|
||||
const char *label; /* 段头标题;NULL=格式名(指向 books[].group 或静态串) */
|
||||
} shelf_row;
|
||||
|
||||
/* 进度时间戳查询(生产=rdprog_load;测试/基线=NULL,全部 ts=0)。 */
|
||||
typedef uint64_t (*shelf_ts_fn)(const char *path);
|
||||
|
||||
typedef struct shelf_scan_report {
|
||||
int nbooks;
|
||||
int truncated; /* 1=有书因 SHELF_MAX_BOOKS 上限被丢弃 */
|
||||
int root_missing; /* 1=root 打不开(无卡/无 Books 目录) */
|
||||
} shelf_scan_report;
|
||||
|
||||
/* 递归扫描 root(子目录深度 ≤2,与旧行为一致),按 ts 降序/书名升序排序后写 books(容量 cap)。
|
||||
* 返回写入本数。目录为空不产生任何记录(段头由 build_rows 按书生成,天然无空段头)。 */
|
||||
int shelf_scan_books(const char *root, shelf_book *books, int cap,
|
||||
shelf_ts_fn ts_fn, shelf_scan_report *rep);
|
||||
|
||||
/* 生成行序列:
|
||||
* folder_mode=0:按格式分段(TXT/FB2/EPUB/MOBI/CBZ,空格式不出段头)——与 M27 前逐行一致;
|
||||
* folder_mode=1:先按目录分组,根目录组「未分类」置顶,其余组按目录名排序;
|
||||
* 组内沿用 books[] 的全局顺序(最近阅读优先、其次书名)。
|
||||
* 返回行数;超过 rowcap 时只写 rowcap 行并返回 rowcap。 */
|
||||
int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode,
|
||||
shelf_row *rows, int rowcap);
|
||||
|
||||
/* 工具(单测直接调用)。 */
|
||||
int shelf_fmt_from_ext(const char *name);
|
||||
const char *shelf_fmt_name(int fmt);
|
||||
void shelf_fmt_size(char *out, int outsz, long size);
|
||||
/* UTF-8 安全截断:最多写 cap-1 字节;若截断点落在多字节序列中间,整段丢弃该残缺字符。
|
||||
* 返回写入字节数(不含 NUL)。 */
|
||||
int shelf_copy_utf8(char *dst, int cap, const char *src);
|
||||
|
||||
#endif /* SHELF_MODEL_H */
|
||||
Reference in new issue
Block a user