「柯南破案,真相只有一个」——阅读页里一个上滑,屏幕下三分之一浮出一层"操作面板"(13.1);这层面板凭什么能接住你的每一次点击,背后是一张动态注册的"触控区域表"在决定谁该响应(13.2);面板中央的格子最是刁钻,一个拨动触控硬件上电下电、一个数着局刷次数决定何时全刷(13.3);最后揭开一条"从阅读页直达设置、改完再回原页"的捷径(13.4)。
核心方法论:真相只有一个——把"上滑 → 浮层 → 12 个按钮 → 命中检测"这条线索链逐环取证,每条结论都能在源码里找到唯一出处
「真相只有一个。阅读页里一个上滑,屏幕下三分之一多出一片浮层(13.1),浮层里 12 个按钮谁被点、谁响应,全由一张每次重绘都会重建的触控区域表说了算(13.2)。表中央的格子最是刁钻:一个拨动触控硬件上电下电,一个数着局刷次数、数够了才舍得全刷一次(13.3)。收尾的捷径更有意思:从阅读页直接钻回设置页,改完还能落回刚才那一页(13.4)。每一条线索,都能在代码里找到唯一的答案——真相只有一个。」
先给"覆盖操作层"(overlay)下个定义,一句话说人话:它是盖在阅读页上方、临时呼出的一层半屏操作面板,用完即走。它只占屏幕下三分之一——代码里 area_y = (page_h * 2) / 3、area_h = page_h - area_y——正文区域保持不动,浮层盖住底部,画满 12 个按钮。呼出它的动作是 UPGLIDE:触摸屏上往上一划(SF32_TouchControls.cpp 里上滑距离超过阈值 SWIPE_THRESHOLD = 100 像素就发这个动作),或者按键长按(SF32_ButtonControls.cpp 里 BUTTON_LONG_PRESSED 也发 UPGLIDE)。这就是 README 说的"上滑/映射动作"。
呼出动作交给 handleEpub 的 case UPGLIDE:先检查 reader->is_overlay_active() 防止重复激活,然后 start_overlay()。这个启动函数一次性把浮层状态复位:overlay_active = true、选中格 overlay_selected = 0、跳页累积 overlay_jump_acc = 0、目标页 overlay_target_page = 当前页 + 1(1-based)。浮层一旦激活,阅读页的 UP/DOWN 就不再翻页,而是变成在 12 个按钮之间左右移动选中格(overlay_move_left/right,索引 (sel ± 1 + 12) % 12 循环)。
12 个按钮排成三行,代码里写得明明白白:const int cols[rows] = {3, 5, 4}。第一行三个格子是 「<」/ 中心 / 「>」——左右两个格子在两种"中心属性"之间切换,中间的格子显示并执行当前属性(13.3 展开);第二行五个格子是跳页步进 -5 / -1 / 目标页显示 / +1 / +5;第三行四个格子是 确认 / 目录 / 书库 / 设置。每次点跳页步进,overlay_adjust_target_page(d) 就把目标页 ±5 / ±1,并夹在 [1, 本章总页数] 之间;确认后(索引 8)current_page = 目标页 - 1 并关闭浮层。
/* src/main.cpp:UPGLIDE 呼出覆盖操作层(防重复激活 + 初始同步触控状态) */
case UPGLIDE:
// 激活阅读页下半屏覆盖操作层
// 防止重复激活
if (!reader->is_overlay_active()) {
reader->start_overlay();
// 默认中心属性为触控开关,初始同步当前触控状态
reader->overlay_set_center_mode_touch();
if (touch_controls)
reader->overlay_set_touch_enabled(touch_controls->isTouchEnabled());
}
break;
/* lib/Epub/EpubList/EpubReader.cpp:render_overlay 的三行布局(3 + 5 + 4)与标签填充(节选) */
// 三行布局:3, 5, 4(第三行加了"设置"按钮)
const int rows = 3;
const int cols[rows] = {3, 5, 4};
const int gap_w = 10;
...
switch (idx)
{
case 0: rt_snprintf(label, cap, "<"); break;
case 1: /* 中心:触摸开关 / 全刷周期(13.3)*/ break;
case 2: rt_snprintf(label, cap, ">"); break;
case 3: rt_snprintf(label, cap, "-5"); break;
case 4: rt_snprintf(label, cap, "-1"); break;
case 5: /* 显示 "目标页/总页数" */ break;
case 6: rt_snprintf(label, cap, "+1"); break;
case 7: rt_snprintf(label, cap, "+5"); break;
case 8: rt_snprintf(label, cap, "确认"); break;
case 9: rt_snprintf(label, cap, "目录"); break;
case 10: rt_snprintf(label, cap, "书库"); break;
case 11: rt_snprintf(label, cap, "设置"); break;
default: label[0] = '\0'; break;
}
| 索引 | 标签 | 作用 |
|---|---|---|
| 0 / 2 | < / > | 切换中心属性:触控开关 ⇄ 全刷周期 |
| 1 | 触摸开关 / 全刷周期 | 显示当前中心属性;执行它(拨触控 / 循环全刷周期) |
| 3 / 4 / 6 / 7 | -5 / -1 / +1 / +5 | 跳页步进,把目标页 ±5 / ±1(夹在 1 与本章总页数之间) |
| 5 | 目标页/总页数 | 显示累积出的目标页(只显示,不响应动作) |
| 8 | 确认 | current_page = 目标页 - 1,关闭浮层(含点浮层外正文区) |
| 9 | 目录 | 进入章节目录页 |
| 10 | 书库 | 返回书库页 |
| 11 | 设置 | 进入阅读设置(13.4,带锚点恢复) |
线索要对齐。注意"防重复激活"这道防线:is_overlay_active() 既挡触摸侧(上滑只在浮层未激活时触发),又挡按键侧(长按同理)。两层输入走同一条 UPGLIDE 管道,却因为这道检查不会互相打架——这是"多种输入共享一个动作语义"的标准写法。
先说两个词。浮层上 12 个按钮,每个都对应屏幕上一块矩形区域——叫触控区域(region);判断"你点下的坐标落在哪块区域里、该触发哪个动作",叫命中检测(hit test)。整个机制的核心是 UIRegionsManager.cpp:它维护一个全局数组 g_area_array[MAX_AREAS],每一项是一个 AreaRect——结构体里只有四个坐标字段 start_x / start_y / end_x / end_y,外加一个 UIAction action 字段,容量上限 MAX_AREAS = 30。
它提供三个函数,分工非常清晰:clear_areas() 把计数 g_area_count 归零(数组内容一并 memset 清零),每次重绘页面都要先清一次;add_area(x, y, w, h) 是动态注册——追加到数组尾部、计数加一,满了返回 false;static_add_area(x, y, w, h, index) 是静态注册——直接写进指定的下标,不占用"尾部追加"的语义。覆盖层的 render_overlay() 用的是动态版(每画一个按钮就 add_area 一次,下标天然 0..11),而设置页用的是静态版(按 SET_TOUCH * 3 这样的公式定位到固定下标,因为设置项的行号是固定的)。
一个容易被忽略的真相:真正的命中检测并不在 UIRegionsManager 里。这个文件只管"区域表的读写",判断坐标落在哪块区域、映射成哪个动作,是触控回调 SF32_TouchControls::tp_rx_indicate 里那个巨大的 switch (ui_state) 干的——每个页面状态一段 if (x >= g_area_array[i].start_x && ...) 逐块比对。区域表是"地",命中检测是"判",两者各司其职。还有个值得如实标注的细节:AreaRect 里那个 action 字段从头到尾没被赋值——动作映射写在触控回调里,区域表只是纯坐标。
对比更清楚:阅读页浮层未激活时的左右翻页,用的不是区域表,而是两个硬编码矩形——x 在 10..200、y 在 10..1010 算上一页,x 在 550..750 算下一页。这就是"动态注册"和"硬编码"两种做法的活例子:区域表随重绘重建、位置灵活,硬编码写死、简单粗暴。浮层激活后命中检测切回区域表:逐个比对 0..11 号按钮,命中就 touch_sel = btn 发 SELECT;若点到浮层外、即正文区(y < area_y),则 touch_sel = 8——等于按了一次"确认",关闭浮层。
/* src/UIRegionsManager.cpp:区域表的清空与两种注册方式 */
void clear_areas()
{
g_area_count = 0;
memset(g_area_array, 0, sizeof(g_area_array));
}
// 动态添加:追加到数组尾部,满了返回 false
bool add_area(int x, int y, int width, int height)
{
if (g_area_count >= MAX_AREAS) {
return false; // 数组已满
}
g_area_array[g_area_count].start_x = x;
g_area_array[g_area_count].start_y = y;
g_area_array[g_area_count].end_x = x + width;
g_area_array[g_area_count].end_y = y + height;
g_area_count++;
return true;
}
// 静态添加:直接写到指定下标(设置页按 SET_* * 3 定位)
bool static_add_area(int x, int y, int width, int height, int index)
{
if(index < 0 || index >= MAX_AREAS) {
return false; // 索引超出范围
}
g_area_array[index].start_x = x;
g_area_array[index].start_y = y;
g_area_array[index].end_x = x + width;
g_area_array[index].end_y = y + height;
return true;
}
/* src/boards/controls/SF32_TouchControls.cpp:浮层激活时的命中检测(READING_EPUB 分支) */
case READING_EPUB:
if (reader && reader->is_overlay_active())
{
// overlay 激活时:检测 12 个按钮的触控区域(由 render_overlay 通过 add_area 注册)
bool hit = false;
for (int btn = 0; btn < 12; btn++)
{
if (x >= g_area_array[btn].start_x && x <= g_area_array[btn].end_x &&
y >= g_area_array[btn].start_y && y <= g_area_array[btn].end_y)
{
touch_sel = btn;
action = SELECT;
hit = true;
break;
}
}
// 点击 overlay 区域外的正文部分 → 关闭 overlay(相当于按确认)
if (!hit)
{
int area_y = (instance->renderer->get_page_height() * 2) / 3;
if (y < area_y)
{
touch_sel = 8; // 确认
action = SELECT;
}
}
}
| API | 行为 | 典型调用方 |
|---|---|---|
clear_areas() | 清空区域表(计数归零 + 数据清零) | 每个页面的渲染函数开头 |
add_area(x, y, w, h) | 动态追加,尾部计数加一;满 30 返回 false | 覆盖层 render_overlay、主页面、天气页 |
static_add_area(x, y, w, h, idx) | 按指定下标写入,不占尾部语义 | 设置页 SET_* * 3、书库/目录列表项 |
| 命中检测 | 坐标 vs g_area_array[i] 逐块比对,映射 UIAction | SF32_TouchControls::tp_rx_indicate 的 switch(ui_state) |
证据要区分"谁画的"和"谁判的"。区域表是纯坐标的"地",动作映射写在触控回调里;AreaRect.action 字段有声明没赋值。读代码时别被结构体字段带偏——结构体里声明了什么,不等于真的用了什么。动手改项目时,若想让区域自带动作,就把 action 填上并在命中检测里直接读取,能少写半页 if。
浮层第一行的中央格子(索引 1)是"执行键",左右两个箭头(索引 0 / 2)负责在两种中心属性之间切换:OverlayCenterMode { CENTER_TOUCH = 0, CENTER_FULL_REFRESH = 1 }。切到"触控开关"时,中央格显示 触摸开关:开/关;切到"全刷周期"时,显示 全刷周期:N次/每次。按中央格,就执行当前属性。
属性一:触控开关。它不只改一个软件标志,还同步触控硬件上电 / 下电——README 原话"同步触控硬件上电/下电"。执行分支里:touch_controls->setTouchEnable(!cur) 翻转软件开关,然后若原来关着就 powerOnTouch()、原来开着就 powerOffTouch()。这两个函数最终调用 rt_device_control(tp_device, RTGRAPHIC_CTRL_POWERON / POWEROFF),真正把触控芯片的电给断了(省电)或重新给上(恢复触控)。连开机都有一条:SF32_TouchControls 构造函数里,若启动时 !touch_enable,就直接把触控设备 POWEROFF——软件开关与硬件电源始终对齐。
属性二:全刷周期。墨水屏局刷久了会残影,所以刷新计数满一个周期就强制全刷一次。选项定义在 epub_screen.cpp:kFullRefreshOptions[] = {5, 10, 20, 0},默认下标 1 即 10 次;screen_cycle_full_refresh_period() 循环切换。执行分支里调 reader->overlay_cycle_full_refresh()(内部就是 screen_cycle_full_refresh_period(true)),再 set_part_disp_times(当前周期值) 应用到底层显示驱动。驱动里 g_part_disp_times = val > 0 ? val : 1,每刷 g_part_disp_times 次(reflesh_times % g_part_disp_times == 0)全刷一次——所以"每次(0)"会被归一成 1,等于每次都全刷。
关键观察:浮层与功能设置页共享同一份全局状态。screen_cycle_full_refresh_period / screen_get_full_refresh_period 是 epub_screen.cpp 里的全局函数,设置页第 3 行和浮层中央格改的是同一个 full_refresh_idx——在设置页把全刷周期改成 20 次,回到浮层中央格看到的也是 20 次。这一设计避免了两处各存一份的"状态分叉",代价是两者永远联动。
/* src/main.cpp:浮层中央格(sel==1)执行当前中心属性 */
else if (sel == 1 || touch_sel == 1)
{
// 中心矩形:根据当前属性执行
if (reader->overlay_is_center_touch())
{
bool cur = touch_controls ? touch_controls->isTouchEnabled() : false;
if (touch_controls)
{
touch_controls->setTouchEnable(!cur);
if (!cur) touch_controls->powerOnTouch(); else touch_controls->powerOffTouch();
}
reader->overlay_set_touch_enabled(!cur);
}
else
{
reader->overlay_cycle_full_refresh(); // 5/10/20/每次(0) 之间循环
set_part_disp_times(reader->overlay_get_full_refresh_value());
}
}
/* src/epub_screen.cpp:全刷周期选项表 + src/boards/display_dbi/epd_display.c 的应用 */
// 全刷周期选项:5、10、20、每次(0)
static const int kFullRefreshOptions[] = {5, 10, 20, 0};
static int full_refresh_idx = 1; // 默认10次
void screen_cycle_full_refresh_period(bool refresh)
{
if(refresh)
full_refresh_idx = (full_refresh_idx + 1) % kFullRefreshOptionsCount;
else
full_refresh_idx = (full_refresh_idx - 1 + kFullRefreshOptionsCount) % kFullRefreshOptionsCount;
}
/* epd_display.c:局刷计数到周期即全刷 */
static int g_part_disp_times = 10; // 局刷 g_part_disp_times-1 次后全刷一次
static int reflesh_times = 0;
void set_part_disp_times(int val)
{
g_part_disp_times = val > 0 ? val : 1; // "每次(0)"归一成 1 → 每次都全刷
reflesh_times = 1;
}
/* 刷新时:if (reflesh_times % g_part_disp_times == 0) { 全刷 } else { 局刷 } */
| 选项 | 下标 | 驱动实际值(g_part_disp_times) | 刷新行为 |
|---|---|---|---|
| 5 次 | 0 | 5 | 局刷 4 次后第 5 次全刷 |
| 10 次(默认) | 1 | 10 | 局刷 9 次后第 10 次全刷 |
| 20 次 | 2 | 20 | 局刷 19 次后第 20 次全刷 |
| 每次(0) | 3 | 1 | reflesh_times % 1 == 0 恒真 → 每次都全刷 |
真相藏在"共享"二字里。设置页与浮层改的是同一个 full_refresh_idx,这才让两处显示永远一致。但也提醒你:全局可变状态是双刃剑——浮层中央格能改全局全刷周期,改动会"泄漏"回设置页。读这类项目时,凡见全局 static 变量,先问一句"还有谁在改它"。
README 把这条捷径标为"新增":覆盖操作层第 12 格「设置」可从阅读页直接进入阅读设置(字体/字号/行距/边距),退出后自动恢复到当前阅读位置。入口代码在 main.cpp 的 sel == 11 分支:先 reader->save_anchor(g_anchor_block, g_anchor_line) 把"当前读到哪"存进两个全局变量,置 g_has_anchor = true,再记下 g_state_before_settings = READING_EPUB,随后 stop_overlay()、切到 READING_SETTINGS 状态并 reading_settings_draw()。
锚点(anchor)是什么?它把"当前页"翻译成"当前页开头在正文里的块/行坐标":save_anchor 调用 parser->get_page_anchor(state.current_page),拿到 PageAnchor{block_index, line_index}。为什么存坐标而不是页码?因为改完字号、行距、边距后整本书重新排版,页码会变——但"第几块正文的第几行"这个语义位置不会变。退出时 restore_by_anchor 反向用锚点 parser->find_page_by_anchor(anchor) 重新找到排版后的新页码,这就是"自动恢复到当前阅读位置"的原理。注意:若锚点找不到(极端情况下排版吞掉了那行),代码只打个 warning,停在当前页不蹦跶——容错处理得很克制。
退出路径由 g_state_before_settings 决定,这也是两处入口的对照。若从功能设置页进(handleSettingsPage 返回 2,此时 g_state_before_settings = SETTINGS_PAGE),退出回设置页;若从阅读页浮层进(g_state_before_settings = READING_EPUB),退出回阅读页——delete reader 后用 handleEpub(renderer, NONE) 按新设置重建阅读器,再 restore_by_anchor 定位。README 明说"退出后自动恢复到当前阅读位置",锚点正是这句话的实现。
/* 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;
}
/* src/main.cpp:READING_SETTINGS 退出 → 按来源回阅读页(用锚点恢复位置) */
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;
}
| 入口 | 设置 g_state_before_settings | 退出回到 | 是否用锚点恢复 |
|---|---|---|---|
| 功能设置页 → 阅读设置 | SETTINGS_PAGE | 设置页 | 否(只回设置页) |
| 阅读页浮层 → 设置格 | READING_EPUB | 阅读页(重建阅读器) | 是(save_anchor/restore_by_anchor) |
这条捷径的证据链最完整:入口存锚点(save_anchor)→ 中间改排版(reading_settings_handle_action)→ 退出重建阅读器 → 锚点找回新页码(restore_by_anchor)。核心思想一句话:排版可变,语义位置不可变——所以存"第几块第几行"而不是"第几页"。以后做任何"改配置后回原位置"的功能,都可以照搬这套锚点思路。
对照 13.1,写出覆盖操作层三行按钮的完整布局(每行几个、索引范围、各自作用)。哪一格只显示、不响应动作?
三行 3 + 5 + 4,共 12 格,索引 0..11;索引 5 显示"目标页/总页数"。
第一行(3 格,索引 0-2):「<」/ 中心属性 / 「>」,左右切换中心属性、中央执行;第二行(5 格,索引 3-7):-5 / -1 / 目标页显示 / +1 / +5;第三行(4 格,索引 8-11):确认 / 目录 / 书库 / 设置。索引 5 只显示当前累积的目标页与本章总页数,不响应动作。
add_area 与 static_add_area 有什么区别?真正的命中检测(坐标→动作)发生在哪个文件、以什么结构组织?AreaRect 里的 action 字段被赋值了吗?
前者追加到尾部,后者写指定下标;命中检测在 SF32_TouchControls.cpp 的 switch(ui_state);action 字段从头到尾没人写。
add_area 动态追加到数组尾部(满了返回 false),适合浮层这种"每画一个按钮注册一个"的场景;static_add_area 直接写到固定下标,适合设置页按 SET_* * 3 公式定位。命中检测在 SF32_TouchControls::tp_rx_indicate 里,按 ui_state 分页面、再逐块比对坐标映射成 UIAction。AreaRect.action 只是结构体里声明了,从未赋值——动作映射写在触控回调里,区域表只存坐标。
浮层中央格有哪两种中心属性?各自按下去会执行什么(特别是"触控开关"为什么能真正省电)?为什么浮层里改的全刷周期在功能设置页也生效?
OverlayCenterMode 两个值;powerOnTouch/powerOffTouch 调 rt_device_control 的 POWERON/POWEROFF;两者共享 epub_screen.cpp 的全局 full_refresh_idx。
两种属性:触控开关(CENTER_TOUCH)与全刷周期(CENTER_FULL_REFRESH)。触控开关按下时,翻转 touch_enable 并调用 powerOnTouch()/powerOffTouch(),后者经 rt_device_control(..., RTGRAPHIC_CTRL_POWERON/POWEROFF) 真正切断/恢复触控芯片电源,所以能省电。全刷周期按下时循环 screen_cycle_full_refresh_period(true) 并 set_part_disp_times 应用到显示驱动。因为浮层与设置页共享 epub_screen.cpp 里的全局 full_refresh_idx,改一处、两处同步显示。
从浮层进阅读设置后,"自动恢复到当前阅读位置"靠什么实现?为什么存的是块/行坐标而不是页码?退出时若锚点找不到,代码会怎么处理?
save_anchor 拿 get_page_anchor;restore_by_anchor 拿 find_page_by_anchor;找不到只 ulog_w 不蹦跳,停在当前页。
入口用 save_anchor(g_anchor_block, g_anchor_line) 把当前页开头的 PageAnchor{block_index, line_index} 存进全局变量;退出时重建阅读器后 restore_by_anchor 用 find_page_by_anchor 在新排版里找到锚点对应的页码并渲染。存坐标而非页码,是因为改字号/行距/边距后整书重排、页码会变,而"第几块正文的第几行"语义位置稳定。若找不到锚点,代码只打 warning 并保持当前页,不会崩溃或乱跳——这是精心设计的容错。