fix(app-iced): M90 r2 —— 找回窗口装饰 + 按需节拍 + 数据根一致 + 假绿守卫

复审 P1-1(产品页丢窗口装饰):shell 加内容槽位 view_with_content(ui, nav, title, content, forced),
app/view.rs 把 pages::page(...) 塞进真外壳;shell::view 保留给 Route::Agent 回落(删了会让私有
view/nav::rail 变死代码,-D warnings 红)。content() 拆到 shell/fallback.rs 让 mod.rs 回到 365 行。
1180 浅/深新图右上角三个 caption 键实测:ink 中心 y=23.5(栏高 48 的一半)、x 中心 1064.5/1110.5/
1156.5 = 46px 分档的 1065/1111/1157(±0.5px),close 命中区右缘 = 1180 窗角。
偏离 #7 改写成「交互面缺失」并注明本轮已修。

P1-2(harness.rs 402 行):tests 外置到 pages/harness/tests.rs(343 + 64 行);
components/gate.rs 的行数守门从走查 components/ 扩到 src/**(新测试当场抓出 app/tests.rs 416 行,
已按同样办法拆成 app/tests.rs + app/tests/presenter.rs)。

P2-1(常驻重绘 + 每拍重建模型):subscription 只在「截图 / 有 Busy-Running / 有 toast / 3 s 派发
观察窗」时订阅 window::frames();refresh_view 加 view_dirty 门。实测交互模式 9 s:只有 2 行 tick
trace(n=30/60,观察窗内),之后 6.5 s 一条都没有;refreshes 全程停在 7。

P2-2(两个数据根):--data-dir 时 SettingsBridge::with_path(config_path(root)),与后端同根。

P3-2(假绿):新增 RecorderBackend(dispatch 只记账、drain_events 恒空)+ launching_only_dispatches
_and_never_echoes_locally;植入本地回显后该测试实测转红(status 变 Downloading · 0%、can_stop 翻
true),撤回后转绿。
P3-3:尺寸字面量守门目录加 app;P3-1:--force-state 经 pages::Model::forced 作用到产品页控件
(hover 图里页头/列表/动作行/rail/caption 全部可见状态层);视觉 P2-2:加载器名统一走 badge()。

gallery 回归改口径:r2 与「主线 + r1」的 gallery 逐字节相同(cmp IDENTICAL);与 r1 记的
c825e1a 基线不同,差异来自主线 M86/M88 的令牌收口,不是本 mission。
This commit is contained in:
AskaEth committed 2026-11-02 15:56:58 +08:00
1 parent b484998fc9
commit 022778a5a7
17 files changed
+980 -496

No files matched your search

+40 -15
View File
@@ -1,17 +1,26 @@
//! 订阅:帧事件(截图闸门 + presenter 的 TICK 节拍)与窗口尺寸事件。
//! 订阅:**按需**帧节拍(presenter 的 TICK)+ 窗口尺寸事件。
//!
//! ## 为什么节拍挂在帧事件上,而不是 `iced::time::every`
//! ## 为什么要「按需」
//!
//! `iced::time::every` 只有 `tokio` / `smol` 两个异步后端提供;本 crate 关掉了 iced 的默认 features,
//! 执行器是 `thread-pool`(M60 踩坑 Q2),拿不到那个函数。想用它就得改 `Cargo.toml` 开一个异步
//! 运行时(不在本 mission 的 scope 内,已报 finding)。
//! iced_winit 在处理完任何一条消息后都会 `request_redraw()`,而 `window::frames()` 监听的是
//! `RedrawRequested` —— 于是「订阅帧 → 收 Frame → update → 再触发重绘」是一条**自持循环**:
//! 只要订阅着,窗口就永远在重绘(M90 r1 就是这样,CPU 空转)。
//!
//! 所以节拍走**帧事件 + 时间闸门**:`window::frames()` 每帧给一条 `Frame`,app 侧按真实流逝时间
//! 判断「够不够 33 ms」,够了才推进一拍 presenter(`backend.tick(elapsed)` / `drain_events`)。
//! 好处是**按真实时间推进**(不是「数了多少帧 × 33 ms」,掉帧也不会把游戏启动进度拖慢),
//! 代价是交互模式下窗口会持续重绘(30 帧的活干在 vsync 的节拍上)。
//! 现在只在**真的需要时钟**的时候订阅([`App::needs_frames`]):
//!
//! 截图模式下这条订阅原本就在(第 3 帧落盘),合并之后两条路径共用同一个计时器。
//! | 条件 | 为什么需要 33 ms 节拍 |
//! |---|---|
//! | 截图通道 | 第 3 帧要落盘 |
//! | 有 `Busy` / `Running` 实例 | 启动进度、日志尾随都靠 `backend.tick` 推进 |
//! | 有未过期的 toast | toast 按毫秒倒计时 |
//! | 刚派发过指令(3 s 观察窗) | 真 host 的异步事件要靠节拍抽出来 |
//!
//! 空闲态**一条帧消息都不来** ⇒ 窗口不重绘、`refresh_view` 不跑(配合 `view_dirty`)。
//! 已知代价:空闲态不再按 `config_poll_due` 轮询设置文件(外部 AI/CLI 改主题要等下次交互才生效)。
//! 要同时拿回「省 CPU」与「后台轮询」,得用 `iced::time::every`,而它需要 Cargo.toml 开
//! tokio/smol(不在本 mission 的 scope,已报 finding)。
use std::time::Instant;
use iced::{window, Subscription};
@@ -21,18 +30,34 @@ use super::state::App;
impl App {
/// 本次进程的订阅表。
pub(super) fn subscription(&self) -> Subscription<Message> {
let mut subscriptions = vec![window::frames().map(|_| Message::Frame)];
let frames = if self.needs_frames() {
window::frames().map(|_| Message::Frame)
} else {
Subscription::none()
};
// 断点 600 / 840 要跟着真窗口宽度走;截图模式的窗口尺寸由参数钉死,不吃 resize。
if !self.has_screenshot() {
subscriptions.push(window::events().map(|(_, event)| Message::Window(event)));
}
let events = if self.has_screenshot() {
Subscription::none()
} else {
window::events().map(|(_, event)| Message::Window(event))
};
Subscription::batch(subscriptions)
Subscription::batch([frames, events])
}
/// 是否订阅帧事件(= 截图模式要帧数闸门)。
pub(super) fn wants_frames(&self) -> bool {
self.has_screenshot()
}
/// 现在要不要**时钟**(见模块文档的表)。
pub(super) fn needs_frames(&self) -> bool {
self.has_screenshot()
|| !self.state.toasts.is_empty()
|| !self.state.running().is_empty()
|| self
.watch_until
.is_some_and(|deadline| Instant::now() < deadline)
}
}