"运筹帷幄之中,决胜千里之外"——看书想要舒服,不是靠临时调,而是把字体、字号、行距、边距这套配置提前摆布好,存下来、开机自动恢复。这章我们就看诸葛亮怎么在设置页上排兵布阵。
核心方法论:运筹帷幄——先摆布清楚六枚棋子,再决定谁进谁出、谁去谁留
「下棋要算三步:第一算棋子有多少,第二算每一步怎么走,第三算这盘棋下完怎么收——收完还要能重开。阅读设置也是三步:设置项有哪些(9.1)、交互怎么转(9.2)、退出怎么存、开机怎么恢复(9.3)。最后一问:这一页和主设置页、阅读页该怎么衔接(9.4)?算清楚了,棋就不会乱。」
阅读设置页面(READING_SETTINGS)是把"阅读体验"的五根旋钮集中在一起的子页面。README 的「阅读设置页面」一节把可选值列成一张表:字体可选 Default(内置)及 TF 卡 /fonts 目录下所有 .ttf / .otf 文件;字号有 24 / 28 / 32 / 36 / 40 / 44 / 48 px 七档;字重有 正常 / 中粗 / 粗体 三档;行距从 1.0x 到 2.0x 六档;边距从 5 到 20 px 六档;最后一行是保存退出。上一章 8.4 已经见过字号和字重那两排数组,这里把它们放进完整的设置项枚举里,看它们如何被统一编号。
实现上,六个设置项就是 reading_settings.cpp 里的 SettingItem 枚举(SETTING_FONT_TYPE、SETTING_FONT_SIZE、SETTING_BOLD、SETTING_LINE_SPACING、SETTING_MARGIN、SETTING_SAVE_EXIT),当前光标位置用一个 g_current_item 全局变量记录。这一章要特别留意两个嵌入式小技巧:一是行距档位用整数存——LINE_SPACINGS[] = {10, 12, 14, 16, 18, 20} 存的其实是"10 倍行距",10 代表 1.0x、12 代表 1.2x,等真正应用时才 / 10.0f 转成浮点,避免在无浮点单元的 MCU 上做无谓的浮点比较;二是中文标签以 UTF-8 转义字符串写在源码里——"\xe6\xad\xa3\xe5\xb8\xb8" 就是"正常",让源码在任意编码环境里都能编译出正确的中文。
绘制时,每行左边画标签、右边画当前值。值得看的是"光标"的画法:reading_settings_draw 先按布局公式算出行高——max_lh = (page_h - 30) / 17,保证 6 个设置项 + 标题 + 提示区能装进一屏;当前项前画一个实心小三角(fill_triangle),外面再套一个外框矩形(draw_rect)突出选中项。字号、行距、边距的值用 snprintf 拼成 < 32 px >、< 1.4x > 这样的格式,字重还会在字体是可变字体时加一个 (VF) 后缀提示。
/* src/reading_settings.cpp:五根旋钮的档位数组(节选) */
/* 字号档位(像素) */
static const int FONT_SIZES[] = {24, 28, 32, 36, 40, 44, 48};
static const int FONT_SIZES_COUNT = sizeof(FONT_SIZES) / sizeof(FONT_SIZES[0]);
/* 加粗等级 */
static const int BOLD_LEVELS[] = {0, 1, 2};
static const char *BOLD_NAMES[] = {
"\xe6\xad\xa3\xe5\xb8\xb8", /* 正常 */
"\xe4\xb8\xad\xe7\xb2\x97", /* 中粗 */
"\xe7\xb2\x97\xe4\xbd\x93" /* 粗体 */
};
/* 行距档位(x10,避免浮点,10 = 1.0x, 12 = 1.2x) */
static const int LINE_SPACINGS[] = {10, 12, 14, 16, 18, 20};
/* 边距档位(像素) */
static const int MARGINS[] = {5, 8, 11, 14, 17, 20};
/* 设置项枚举 */
typedef enum {
SETTING_FONT_TYPE = 0,
SETTING_FONT_SIZE,
SETTING_BOLD,
SETTING_LINE_SPACING,
SETTING_MARGIN,
SETTING_SAVE_EXIT,
SETTING_COUNT
} SettingItem;
/* src/reading_settings.cpp:绘制当前值(节选) */
case SETTING_FONT_TYPE: {
char name_buf[32];
utf8_truncate(name_buf, font_manager_get_name(g_font_type_idx), max_name_bytes);
snprintf(val_buf, sizeof(val_buf), "< %s >", name_buf);
break;
}
case SETTING_FONT_SIZE:
snprintf(val_buf, sizeof(val_buf), "< %d px >", FONT_SIZES[g_font_size_idx]);
break;
case SETTING_BOLD:
if (epd_font_ft_is_variable_weight()) {
snprintf(val_buf, sizeof(val_buf), "< %s (VF) >", BOLD_NAMES[g_bold_idx]);
} else {
snprintf(val_buf, sizeof(val_buf), "< %s >", BOLD_NAMES[g_bold_idx]);
}
break;
case SETTING_LINE_SPACING:
snprintf(val_buf, sizeof(val_buf), "< %d.%dx >",
LINE_SPACINGS[g_line_spacing_idx] / 10,
LINE_SPACINGS[g_line_spacing_idx] % 10);
break;
case SETTING_MARGIN:
snprintf(val_buf, sizeof(val_buf), "< %d px >", MARGINS[g_margin_idx]);
break;
| 设置项 | 可选值 | 代码档位 | 应用方式 |
|---|---|---|---|
| 字体 | Default + /fonts 的 ttf/otf | font_manager_get_count() | font_manager_select(8.3) |
| 字号 | 24 / 28 / 32 / 36 / 40 / 44 / 48 px | FONT_SIZES[] | epd_font_ft_set_size |
| 字重 | 正常 / 中粗 / 粗体 | BOLD_LEVELS[] | epd_font_ft_set_bold |
| 行距 | 1.0x ~ 2.0x(六档) | LINE_SPACINGS[](x10) | epd_font_ft_set_line_spacing |
| 边距 | 5 / 8 / 11 / 14 / 17 / 20 px | MARGINS[] | renderer->set_margin_left/right |
| 保存退出 | — | SETTING_SAVE_EXIT | reading_settings_apply + save |
棋子先编号,后作战。六个设置项用一个枚举统一编号(0–5),后续 UP/DOWN、SELECT、持久化全都围着这个编号转——这就是"运筹"的第一层:先把维度定下来,再谈怎么动。你写任何菜单/表单类 UI 时,都该先问一句:选项的"编号表"建好了吗?
交互的规矩 README 一句话讲完了:UP / DOWN 在设置项之间上下移动;SELECT 在当前项上循环切换值;光标停在"保存退出"时按 SELECT 保存并退出。落到代码就是 reading_settings_handle_action 里对三种动作的 switch。UP/DOWN 走"减一 / 加一 + 取模":g_current_item = (g_current_item + 1) % SETTING_COUNT,到了 SETTING_COUNT - 1 再按 UP 会绕回 0——循环移动,光标永不越界。每次移动后立刻 reading_settings_draw(renderer) 重绘,返回 true 表示"还留在设置页"。
SELECT 是重头戏。它在当前项上把对应下标 +1 后再 % 档位数 循环:字号 g_font_size_idx = (g_font_size_idx + 1) % FONT_SIZES_COUNT,行距、边距同理,一直按就会绕回第一档。只有一项例外——光标在 SETTING_SAVE_EXIT 时,SELECT 会先检查 g_settings_changed(本次进入后有没有改过任何值),改过才调用 reading_settings_apply + reading_settings_save 落盘,然后 return false 通知上层"设置页结束、该回去了";没改过就跳过保存、直接退出。这个"变了才存"的小开关,避免用户进进出出反复写 TF 卡。
真正把新值"生米煮成熟饭"的是 reading_settings_apply。它把五根旋钮翻译成五条底层调用:字体变化最重——要先 renderer->show_busy() 显示忙碌图标,再 font_manager_select 切换(第 8 章讲过,失败返回非 0 时会保留当前字体并回退下标);其余四个直接调 epd_font_ft_set_size、epd_font_ft_set_bold、epd_font_ft_set_line_spacing、renderer->set_margin_left/right。注意行距在这里才从整数还原成浮点:LINE_SPACINGS[idx] / 10.0f。另外别漏了触控:reading_settings_draw 里 add_area() 为每一行注册了整行触控区域,reading_settings_set_current_item 供触控回调直接选中某一行——按键与触控(第 5 章)走同一条设置逻辑。
/* src/reading_settings.cpp:按键处理(节选) */
bool reading_settings_handle_action(Renderer *renderer, UIAction action)
{
switch (action) {
case UP: // 上移一项(循环)
if (g_current_item > 0)
g_current_item = (SettingItem)(g_current_item - 1);
else
g_current_item = (SettingItem)(SETTING_COUNT - 1);
reading_settings_draw(renderer);
return true;
case DOWN: // 下移一项(循环)
if (g_current_item < SETTING_COUNT - 1)
g_current_item = (SettingItem)(g_current_item + 1);
else
g_current_item = (SettingItem)0;
reading_settings_draw(renderer);
return true;
case SELECT: // 循环切换当前项的值
switch (g_current_item) {
case SETTING_FONT_TYPE:
g_font_type_idx = (g_font_type_idx + 1) % font_count;
g_settings_changed = true;
g_font_changed = true; // 字体切换单独记账
break;
case SETTING_FONT_SIZE:
g_font_size_idx = (g_font_size_idx + 1) % FONT_SIZES_COUNT;
g_settings_changed = true;
break;
/* SETTING_BOLD / LINE_SPACING / MARGIN 同理 ... */
case SETTING_SAVE_EXIT: // 光标停在"保存退出"
if (g_settings_changed) { // 变了才存
reading_settings_apply(renderer);
reading_settings_save();
g_settings_changed = false;
g_font_changed = false;
}
g_current_item = SETTING_FONT_TYPE;
return false; /* 退出设置页面 */
default:
break;
}
reading_settings_draw(renderer);
return true;
default:
return true;
}
}
/* src/reading_settings.cpp:应用设置(节选) */
void reading_settings_apply(Renderer *renderer)
{
int new_size = FONT_SIZES[g_font_size_idx];
float new_spacing = LINE_SPACINGS[g_line_spacing_idx] / 10.0f;
int new_margin = MARGINS[g_margin_idx];
int new_bold = BOLD_LEVELS[g_bold_idx];
if (g_font_changed) {
renderer->show_busy(); // 切换字体较慢,先显示忙碌图标
int ret = font_manager_select(g_font_type_idx, new_size);
if (ret != 0) {
// 失败:保留当前字体,下标回退
g_font_type_idx = font_manager_get_current();
}
} else {
epd_font_ft_set_size(new_size);
}
epd_font_ft_set_bold(new_bold);
epd_font_ft_set_line_spacing(new_spacing);
renderer->set_margin_left(new_margin);
renderer->set_margin_right(new_margin);
}
| 动作 | 在阅读设置页的行为 | 返回值 / 效果 |
|---|---|---|
UP | 光标上移一项(到顶绕回"保存退出") | 重绘,返回 true |
DOWN | 光标下移一项(到底绕回"字体") | 重绘,返回 true |
SELECT(普通项) | 当前值下标 +1 再对档位数取模,循环切换 | 置 g_settings_changed,重绘 |
SELECT(保存退出) | 有改动才 apply + save;无论有无都退出 | 返回 false(退出设置页) |
| 触控 | add_area 注册整行区域,点击选中对应项 | 经 reading_settings_set_current_item 同步 |
循环取值用"下标 +1 取模"而不是"值本身 +1":因为你操作的是"第几档"而不是"档位值",下标永远不会越界,后面持久化也是存下标对应的档位值。把"用户看到的档位"和"内部的下标"分开记账,是这类菜单的通用棋路。
持久化(persistence)的意思是:把用户这次的配置写到不掉电的存储里,下次开机还能读回来——在阅读器里,就是存到 TF 卡的 /settings.cfg。移植版选了一个最朴素的格式:key=value 的纯文本,一行一个键,结尾 \n 分隔。为什么不用 JSON?因为这里只有五个固定键,一个 snprintf 就能写完、一个 strtok 就能读完,还方便用户拔卡下来用电脑手工改。保存端是 reading_settings_save:open(SETTINGS_FILE_PATH, O_WRONLY | O_CREAT | O_TRUNC) 打开(不存在就创建、打开即清空),把 font_name / font_size / bold / line_spacing / margin 五个键拼进一个 256 字节的缓冲,一次 write 写完再 close。
加载端 reading_settings_load(Renderer *) 正好是逆过程:open(O_RDONLY) 读入,用 strtok(buf, "\n") 按行切,逐行用 strncmp 匹配前缀、atoi 取数字。读到五个值后,用 9.1 的 find_index 把数字"吸"回最近的档位下标——比如配置里 font_size=30 不在档位表里,就回退到默认下标 0(24px)。真正见功夫的是字体回退:先 find_font_by_name(font_name) 在字体表里找名字,找不到(比如这次没插 TF 卡、上次选的外部字体不在),日志打一条 Font not found, fallback to Default 并把 g_font_type_idx 归 0;找到的是内置字体(下标 0)就直接用;找到外部字体才 font_manager_select 真正加载,加载失败同样回退 0。字体这一步比较慢,加载时会先 renderer->show_busy()。
调用时机在 main.cpp 的启动序列里,顺序很关键:board->start_filesystem() 先挂起 TF 卡,font_manager_init("/fonts") 扫描字体(第 8 章),然后立刻 reading_settings_load(renderer) 恢复上次的阅读设置,最后才进主循环画主页面。也就是说——用户上次调的字体、字号、行距、边距,在屏幕亮起来之前就已经生效了,你看到的第一页就是上次的样子。README 正是这么承诺的:"设置以 key=value 文本格式保存于 TF 卡 /settings.cfg,下次开机自动加载;若指定字体文件不可用,自动回退到内置默认字体。"文档与代码,一字不差。
/* src/reading_settings.cpp:保存到 /settings.cfg */
#define SETTINGS_FILE_PATH "/settings.cfg"
void reading_settings_save(void)
{
int fd = open(SETTINGS_FILE_PATH, O_WRONLY | O_CREAT | O_TRUNC);
if (fd < 0) {
rt_kprintf("[settings] Cannot save to %s\n", SETTINGS_FILE_PATH);
return;
}
char buf[256];
int len = snprintf(buf, sizeof(buf),
"font_name=%s\n"
"font_size=%d\n"
"bold=%d\n"
"line_spacing=%d\n"
"margin=%d\n",
font_manager_get_name(g_font_type_idx),
FONT_SIZES[g_font_size_idx],
BOLD_LEVELS[g_bold_idx],
LINE_SPACINGS[g_line_spacing_idx],
MARGINS[g_margin_idx]);
write(fd, buf, len);
close(fd);
}
/* src/reading_settings.cpp:开机加载与字体回退(节选) */
/* 按行解析 key=value ... */
char *line = strtok(buf, "\n");
while (line) {
char *cr = strchr(line, '\r');
if (cr) *cr = '\0'; // 兼容 Windows 换行
if (strncmp(line, "font_name=", 10) == 0) {
strncpy(font_name, line + 10, sizeof(font_name) - 1);
} else if (strncmp(line, "font_size=", 10) == 0) {
font_size = atoi(line + 10);
}
/* bold / line_spacing / margin 同理 ... */
line = strtok(NULL, "\n");
}
/* 字体回退:找不到名字或加载失败 → Default(下标 0) */
int font_idx = find_font_by_name(font_name);
if (font_idx < 0) {
rt_kprintf("[settings] Font '%s' not found, fallback to Default\n", font_name);
g_font_type_idx = 0; // 内置 Default 永远兜底
} else if (font_idx == 0) {
g_font_type_idx = 0;
} else {
renderer->show_busy();
int ret = font_manager_select(font_idx, FONT_SIZES[g_font_size_idx]);
if (ret != 0) { g_font_type_idx = 0; } // 加载失败同样回退
}
| 配置键 | 含义 | 示例 | 缺失/异常时的行为 |
|---|---|---|---|
font_name | 选中的字体名 | font_name=songti.ttf | 找不到 → 回退 Default |
font_size | 字号(像素) | font_size=32 | 不在档位表 → find_index 回默认 |
bold | 字重档位 | bold=1 | 同上,回默认 0 |
line_spacing | 行距(x10 整数) | line_spacing=16(1.6x) | 同上,回默认 12 |
margin | 边距(像素) | margin=11 | 同上,回默认 8 |
持久化的"持久"二字,赢在兜底:数字对不上档位表就吸回最近档,字体找不到就退回内置,文件不存在就全用默认。三步兜底层层收口,无论卡里躺着什么"残缺配置",开机都不会白屏。写配置文件系统时,把"读回来一定合法"当作验收标准。
阅读设置页不是独立王国,它有两条入口、且退出后"回到哪"要按来路决定。第一条入口来自功能设置页(SETTINGS_PAGE):README 说"通过功能设置 → 阅读设置"进入。代码上,handleSettingsPage 的六个列表项里第 5 项是"阅读设置",用户选中后按 SELECT,函数 return 2;main.cpp 收到返回值 2 就把 g_state_before_settings 记成 SETTINGS_PAGE,状态切到 READING_SETTINGS 并立刻画设置页。第二条入口更妙——阅读页的覆盖操作层(第 13 章会细讲)里也有一格"设置":阅读中触发 UPGLIDE 呼出覆盖层,选到设置格按 SELECT,代码先 reader->save_anchor(...) 把"读到哪一块、哪一行"存进锚点,再切进 READING_SETTINGS。所谓锚点(anchor),就是"阅读位置的书签"——因为改字号、行距、边距后整个排版都会重排,页码会变,必须记"块 + 行"而不是"第几页"。
为什么退出时要分来路?因为两条入口的"家"不一样。main.cpp 里 READING_SETTINGS 分支这样收尾:reading_settings_handle_action 返回 false(用户按了保存退出)后,看 g_state_before_settings——若是 READING_EPUB,就删掉旧 reader、按锚点 restore_by_anchor(g_anchor_block, g_anchor_line) 重新定位并渲染,回到刚才读的那一行;若是 SETTINGS_PAGE,就回功能设置页继续改别的(比如触控开关、超时策略)。这个 g_state_before_settings 就是"来路记账",正是 README 说的"退出后自动恢复到当前阅读位置"。
把这一套串起来,就是本章的"帷幄":一个阅读设置页 + 五根旋钮 + 一个保存退出 + 一份 /settings.cfg + 两条入口 + 一套按来路返回的出口。设置项枚举是棋盘,g_current_item 是落子,g_state_before_settings 是后手记忆,reading_settings_save/load 是存棋谱——用户怎么折腾,棋都不会下乱。这也是为什么 8.3 的字体切换、这一章的行距边距,都要归口到"设置"这一根主线上:体验的每一处细节,都在一个地方被摆布。
/* src/main.cpp:设置页返回 2 → 进入阅读设置(节选) */
case SETTINGS_PAGE:
{
int settings_result = handleSettingsPage(renderer, ui_action, needs_redraw);
if (settings_result == 1) {
ui_state = MAIN_PAGE; // 回主页
handleMainPage(renderer, NONE, true);
} else if (settings_result == 2) {
g_state_before_settings = SETTINGS_PAGE; // 记账:从功能设置页来的
ui_state = READING_SETTINGS;
reading_settings_draw(renderer);
}
break;
}
/* 阅读设置页退出:按来路回到阅读页或功能设置页 */
case READING_SETTINGS:
{
bool still_in_settings = reading_settings_handle_action(renderer, ui_action);
if (!still_in_settings) {
if (g_state_before_settings == READING_EPUB) {
ui_state = READING_EPUB; // 从阅读页来的 → 回阅读页
delete reader;
reader = nullptr;
handleEpub(renderer, NONE);
if (g_has_anchor && reader) {
// 按锚点恢复阅读位置(排版已重排,页码不可靠)
reader->restore_by_anchor(g_anchor_block, g_anchor_line);
reader->render();
g_has_anchor = false;
}
} else {
ui_state = SETTINGS_PAGE; // 从功能设置页来的 → 回去
(void)handleSettingsPage(renderer, NONE, true);
}
}
return;
}
/* src/main.cpp:阅读页覆盖操作层的"设置"格(节选) */
else if (sel == 11 || touch_sel == 11) // 设置(阅读设置)
{
// 保存锚点:记住"读到哪一块、哪一行"
reader->save_anchor(g_anchor_block, g_anchor_line);
g_has_anchor = true;
g_state_before_settings = READING_EPUB; // 记账:从阅读页来的
renderer->set_margin_bottom(0);
reader->stop_overlay();
ui_state = READING_SETTINGS;
reading_settings_draw(renderer);
touch_sel = -1;
return;
}
| 入口 | 触发路径 | 来路记账 | 保存退出后回到 |
|---|---|---|---|
| 功能设置页 | 主页面 → 进入设置 → 阅读设置(SELECT) | SETTINGS_PAGE | 功能设置页 |
| 阅读页覆盖层 | 阅读页 UPGLIDE → 覆盖层"设置"格(SELECT) | READING_EPUB + 锚点 | 阅读页,锚点恢复位置 |
"进"分两路,"退"也分两路——但判断只用一条规则:g_state_before_settings 里记的是什么就回哪。复杂功能最怕"多条入口、单一出口":只要进去之前先记账,出来时看账本行事,再多入口也不会迷路。这就是运筹帷幄在代码里的样子。
阅读设置页有几枚"棋子"(设置项)?各自的可选值是什么?哪一项在代码里是特殊处理的(不参与"循环取值")?
对照 9.1 的表格:字体/字号/字重/行距/边距/保存退出;"保存退出"不是调值,而是触发保存与退出。
六枚:字体(Default + /fonts)、字号(24–48px 七档)、字重(正常/中粗/粗体)、行距(1.0x–2.0x 六档)、边距(5–20px 六档)、保存退出。SETTING_SAVE_EXIT 特殊:SELECT 时不做"下标 +1 取模",而是有改动就 reading_settings_apply + reading_settings_save,然后 return false 退出页面。
为什么 SELECT 切换值要用"下标 +1 再对档位数取模"?行距为什么要用整数 x10 存储,而不是直接存 1.0 / 1.2 这样的浮点?g_settings_changed 起什么作用?
取模保证下标永不出界、且能绕回第一档;行距 x10 避免在 MCU 上做浮点比较;g_settings_changed 让"没改过就不写 TF 卡"。
用户操作的是"第几档"(下标),% FONT_SIZES_COUNT 等取模让下标永远落在合法区间并循环回绕。行距用 LINE_SPACINGS[]={10,12,...} 存"10 倍行距"的整数,应用时才 /10.0f 转浮点,规避在无浮点单元的 MCU 上频繁做浮点运算与比较。g_settings_changed 记录本次进入设置页后是否改过任何值,只有改过才在"保存退出"时写 /settings.cfg,避免无效的 TF 卡写入。
假设 TF 卡 /settings.cfg 内容是 font_name=MyFont.ttf、font_size=30、bold=1、line_spacing=15、margin=9,但这次开机没插 TF 卡、/fonts 里没有 MyFont.ttf。逐项说明加载后各设置实际落到哪个值。
find_index 会把不在档位表里的数字吸回最近合法档位的默认下标;find_font_by_name 找不到名字就回退下标 0(Default)。
字号 30 不在 {24,28,32,...},find_index 返回默认下标 0 → 24px;行距 15 不在 {10,12,14,16,...} → 回默认下标 1 → 1.2x;边距 9 不在 {5,8,11,...} → 回默认下标 1 → 8px;字重 1 合法 → 中粗。字体 MyFont.ttf 因没插卡在字体表里找不到,find_font_by_name 返回 -1,打日志 "Font not found, fallback to Default",g_font_type_idx = 0 用内置 Default——三层兜底保证不白屏。
为什么从阅读页进阅读设置要记"块 + 行"的锚点,而不是记页码?改完字号/行距/边距返回阅读页时,代码做了哪几步?试想如果不做锚点恢复会怎样。
改字号行距后整页重排,页码含义会变;流程是 save_anchor → 退出时 delete reader → 重新 handleEpub → restore_by_anchor + render。
改字号、行距、边距会改变每一页能放多少行,整本书的分页都会重排,此时"第几页"已经失去意义,而"读到哪个文本块、第几行"是稳定标识,所以锚点记 block + line。退出时:delete reader 释放旧阅读器 → handleEpub 重新加载 → restore_by_anchor(g_anchor_block, g_anchor_line) 用锚点重定位 → reader->render() 渲染。若不做锚点恢复,用户改完设置回到阅读页会跳到错误位置甚至停在书开头,阅读体验完全断裂。