fix(app-iced): M90 r3 —— 空闲 1Hz 心跳恢复配置热切 + 导航轨口径订正 + 文档收口

复审 P2-2(空闲态掐时钟 ⇒ config_poll_due 再也不跑):
- 空闲态改用 1 Hz 自续心跳(Task::perform(sleep(1s)) → Message::Heartbeat),忙碌态仍用
  window::frames() + 33ms 闸门;两条时钟不并行(心跳在 needs_frames() 为真时只续排不推进)。
- ticks 改成「33 ms 一拍」记账(1 s 心跳记 30 拍),config 轮询判据从「整除」改成「跨越」——
  实测整除版在 ticks=137 之后每拍 +30 永远不命中,配置轮询静默死掉。
- 顺手修一个真 bug:Message::SystemTheme 原来无条件 set_flavor,会把配置文件里的显式主题盖掉
  (优先级现在是 --theme > 配置文件 > 系统),否则「改 launcher.json 主题 1 秒内生效」验收根本看不到。
- 实测:14 s 交互跑,空闲段 [tick] clock=idle-1hz 每秒一行、frames 冻结在 2947、refreshes 冻结在 6;
  t≈7s / t≈11s 两次改 launcher.json,各自 ≤1 s 内打出 [settings] 配置文件变了 + [theme] 翻转。

复审 P2-1(导航轨口径):
- pages/mod.rs 交付表改成事实(产品路径的 rail 由 shell/nav.rs 画,组件版只给 harness/单测,
  像素等价是复审判的),补偏离表第 10 条(两套 rail 各测各的,漂移没人拦);
- pages/tests.rs 的 the_real_rail_agrees_with_the_layout_system 注释同步订正。

P3 文档五条:shell 的「8px 拖动热区」改成事实(拖动面=整条 48px 标题栏到第一个 caption;
缩放热区 4px)、state/update 的「每拍重建视图模型」改成 view_dirty 门控、message 的 Frame 注释
改成「只在忙碌时订阅」、pages/model 的 From<&AppState> 改成自由函数 model(..)、pages/tests 的守门
目录补 src/app。

r3 浅/深产品图与 r2 逐字节相同(视觉零变化)。
This commit is contained in:
AskaEth committed 2026-11-02 15:56:59 +08:00
1 parent 022778a5a7
commit 6b66adff44
9 files changed
+254 -42

No files matched your search

+23 -4
View File
@@ -6,7 +6,7 @@
//! `RedrawRequested` —— 于是「订阅帧 → 收 Frame → update → 再触发重绘」是一条**自持循环**:
//! 只要订阅着,窗口就永远在重绘(M90 r1 就是这样,CPU 空转)。
//!
//! 现在只在**真的需要时钟**的时候订阅([`App::needs_frames`]):
//! 现在只在**真的需要 33 ms 节拍**的时候订阅([`App::needs_frames`]):
//!
//! | 条件 | 为什么需要 33 ms 节拍 |
//! |---|---|
@@ -16,9 +16,28 @@
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
//!
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
//! 已知代价:空闲态不再按 `config_poll_due` 轮询设置文件(外部 AI/CLI 改主题要等下次交互才生效)。
//! 要同时拿回「省 CPU」与「后台轮询」,得用 `iced::time::every`,而它需要 Cargo.toml 开
//! tokio/smol(不在本 mission 的 scope,已报 finding)。
//!
//! ## 空闲态的时钟从哪来(M90 r3 / P2-2)
//!
//! r2 把空闲的帧订阅掐掉之后,`config_poll_due` 就再也没被调用过 —— 外部 CLI / AI 工具改
//! `launcher.json` 后主题**永不生效**(不是「等下次交互」,是「等下一次 dispatch」)。这与
//! D4「AI 能读改 ULL 配置」直接冲突。
//!
//! 所以空闲态换成一个 **1 Hz 的自续心跳**([`crate::app::message::Message::Heartbeat`],
//! 由 `Task::perform(sleep(1s))` 驱动,见 `update::heartbeat`):每秒醒一次、推进一拍
//! presenter(内部按 33 ms 折算 30 拍,于是 `config_poll_due` 仍是「约 1 秒比对一次 mtime」)。
//!
//! 为什么不用 `window::frames()` + 1 s 闸门来实现这个低频时钟:帧订阅**本身就是**自持重绘循环
//! (iced_winit 处理完任何消息都 `request_redraw()`),闸门只能省掉 `advance()` 里的活,省不掉
//! 整窗重绘——实测软件渲染约 **500 帧/秒**。心跳是「每秒一帧」,差三个数量级。
//!
//! ## 已知代价(都登记,不藏)
//!
//! 1. **忙碌态仍是自持帧循环**:`Running`/`Busy` 一天不结束,就一天按帧刷(软件渲染实测约
//! 970 帧/秒,refreshes 不增长 = 纯像素空转)。功能上需要 tick 来发现游戏退出,但「整段
//! 运行期近千帧/秒重绘」是真实开销;要收口得让 `Running` 走更慢的时钟(follow-up)。
//! 2. 心跳用 `thread::sleep` 占线程池一个线程 1 秒;1 Hz 下可接受,但不是优雅解法(正解仍是
//! `iced::time::every` + tokio/smol,Cargo.toml 在 scope 外,已报 finding)。
use std::time::Instant;