复审 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 逐字节相同(视觉零变化)。
333 lines
15 KiB
Rust
333 lines
15 KiB
Rust
//! `update`:消息分支的**唯一**落点(含「用户动作 → 后端指令」的派发与事件回流)。
|
||
//!
|
||
//! ## 数据流的三个入口(presenter crate 文档 §2)
|
||
//!
|
||
//! ```text
|
||
//! 用户动作 ──dispatch(AppCommand)──► backend ──► drain_events() ──► AppState::apply ──► 视图模型
|
||
//! ▲ ▲
|
||
//! └── pages::Message(导航/启动/停止)──────────────────┘ 忙碌 33 ms / 空闲 1 Hz
|
||
//! ```
|
||
//!
|
||
//! 三条纪律:
|
||
//!
|
||
//! 1. **不做乐观回显**:点了「Play」不改本地状态,等 `InstanceRuntimeChanged` 事件回来才变
|
||
//! (否则事件与本地状态打架时,磁贴与进度条会各说一套);
|
||
//! 2. **状态只有一个写入口**:`AppState::apply`(事件)与 `AppState::navigate`(路由);
|
||
//! 3. **只在状态变了才重建视图模型**:`refresh_view()` 是快照 → 页面的唯一转换点,
|
||
//! 由 `view_dirty` 门控(M90 r2/P2-1 起;此前是每拍无条件重建)。
|
||
//!
|
||
//! ## 两个时钟(M90 r3 / P2-2)
|
||
//!
|
||
//! | 场景 | 时钟 | 周期 | 谁触发 |
|
||
//! |---|---|---|---|
|
||
//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
|
||
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] |
|
||
//!
|
||
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
|
||
|
||
use std::time::{Duration, Instant};
|
||
|
||
use iced::{window, Task};
|
||
|
||
use unlucky_design::color::SchemeFlavor;
|
||
use unlucky_presenter::dto::AppCommand;
|
||
use unlucky_presenter::prefs::ThemeMode;
|
||
use unlucky_presenter::router::Route;
|
||
use unlucky_presenter::theme::CONFIG_POLL_TICKS;
|
||
|
||
use crate::pages;
|
||
|
||
use super::message::{route_of, Message};
|
||
use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW};
|
||
use super::CAPTURE_AT_FRAME;
|
||
|
||
impl App {
|
||
/// 一条消息进来,改状态并返回后续任务。
|
||
pub(super) fn update(&mut self, message: Message) -> Task<Message> {
|
||
match message {
|
||
Message::Boot => self.on_boot(),
|
||
Message::WindowReady(id) => {
|
||
self.window = id;
|
||
Task::none()
|
||
}
|
||
Message::SystemTheme(mode) => {
|
||
let flavor = match mode {
|
||
iced::theme::Mode::Dark => SchemeFlavor::Dark,
|
||
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
|
||
};
|
||
eprintln!("[theme] 系统主题 = {mode:?}");
|
||
// 优先级:`--theme` > 配置文件里的 `appearance.theme_mode` > 系统。
|
||
// 早先这里无条件盖掉,于是「配置文件写 dark、系统是浅色」会出现
|
||
// `light -> dark -> light` 的一次抖动,配置永远不生效(M90 r3 顺手修)。
|
||
if self.options.theme.is_none() && self.theme_mode == ThemeMode::Auto {
|
||
self.set_flavor(flavor);
|
||
}
|
||
Task::none()
|
||
}
|
||
Message::Frame => self.on_frame(),
|
||
Message::Heartbeat => self.on_heartbeat(),
|
||
Message::Window(event) => {
|
||
if let window::Event::Resized(size) = event {
|
||
self.width = size.width;
|
||
self.view_dirty = true;
|
||
self.refresh_view();
|
||
}
|
||
Task::none()
|
||
}
|
||
Message::Page(message) => {
|
||
self.on_page(message);
|
||
Task::none()
|
||
}
|
||
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
|
||
Message::Select(nav) => {
|
||
self.state.navigate(route_of(nav));
|
||
self.view_dirty = true;
|
||
self.refresh_view();
|
||
Task::none()
|
||
}
|
||
Message::DragWindow => match self.window {
|
||
Some(id) => window::drag(id),
|
||
None => Task::none(),
|
||
},
|
||
Message::ResizeStart(direction) => match self.window {
|
||
Some(id) => window::drag_resize(id, direction),
|
||
None => Task::none(),
|
||
},
|
||
Message::Minimize => match self.window {
|
||
Some(id) => window::minimize(id, true),
|
||
None => Task::none(),
|
||
},
|
||
Message::ToggleMaximize => match self.window {
|
||
Some(id) => window::toggle_maximize(id),
|
||
None => Task::none(),
|
||
},
|
||
Message::Close => iced::exit(),
|
||
Message::Captured(screenshot) => self.on_captured(&screenshot),
|
||
Message::Demo(message) => {
|
||
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
|
||
eprintln!("[gallery] {} pressed", message.id());
|
||
Task::none()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 启动:问窗口句柄 → 让后端发布首屏快照 → 决定主题。
|
||
///
|
||
/// **首屏快照在 `Boot` 就抽干**(不是等第一拍):截图通道在第 3 帧落盘,33 ms 的节拍
|
||
/// 未必来得及到达,等节拍会把空页面拍进图里。
|
||
fn on_boot(&mut self) -> Task<Message> {
|
||
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
||
|
||
// 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。
|
||
// 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。
|
||
self.apply_config_theme();
|
||
|
||
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
|
||
if self.options.wants_system_theme() {
|
||
tasks.push(iced::system::theme().map(Message::SystemTheme));
|
||
}
|
||
|
||
// 「重新扫描实例目录 + 全部页面快照」:真 host 在这里发首批事件,mock 在构造时已预置
|
||
self.dispatch(AppCommand::RefreshAll);
|
||
self.auto_select();
|
||
self.view_dirty = true;
|
||
self.refresh_view();
|
||
|
||
// 空闲心跳从这里开始自续(它是唯一能在「没有帧事件」时戳醒 app 的时钟)
|
||
tasks.push(heartbeat());
|
||
|
||
Task::batch(tasks)
|
||
}
|
||
|
||
/// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。
|
||
///
|
||
/// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
|
||
/// app,心跳链必须一直在,才能把低频时钟接回去。忙碌期间它只是每秒醒一次、什么都不做。
|
||
fn on_heartbeat(&mut self) -> Task<Message> {
|
||
if !self.needs_frames() {
|
||
let now = Instant::now();
|
||
let elapsed = now.saturating_duration_since(self.last_tick).max(IDLE_TICK);
|
||
self.last_tick = now;
|
||
self.advance(elapsed);
|
||
}
|
||
heartbeat()
|
||
}
|
||
|
||
/// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。
|
||
///
|
||
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
|
||
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
|
||
fn on_frame(&mut self) -> Task<Message> {
|
||
self.frames += 1;
|
||
|
||
let now = Instant::now();
|
||
let elapsed = now.saturating_duration_since(self.last_tick);
|
||
if elapsed >= TICK {
|
||
self.last_tick = now;
|
||
self.advance(elapsed);
|
||
}
|
||
|
||
// 主题在启动时就已确定(截图模式必有主题,见 cli 模块文档),这里只剩帧数闸门
|
||
if !self.wants_frames() || self.frames < CAPTURE_AT_FRAME {
|
||
return Task::none();
|
||
}
|
||
match self.window {
|
||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||
None => Task::none(),
|
||
}
|
||
}
|
||
|
||
/// 推进一拍:后端消化时间 → 抽干事件 → 过期 toast → 偶发重读配置文件。
|
||
///
|
||
/// `elapsed` 是真实流逝时间(毫秒级抖动是正常的:`MockBackend` 的脚本按累计时长走)。
|
||
pub(super) fn advance(&mut self, elapsed: Duration) {
|
||
self.backend.tick(elapsed);
|
||
self.pump();
|
||
|
||
self.state
|
||
.toasts
|
||
.expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX));
|
||
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
|
||
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
|
||
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
|
||
let ticks = u64::try_from(ticks).unwrap_or(u64::MAX).max(1);
|
||
let bucket_before = self.ticks / 30;
|
||
self.ticks += ticks;
|
||
let bucket_after = self.ticks / 30;
|
||
|
||
// 外部(AI / CLI)改了配置文件:主题跟着换,其余外观项等玻璃/背景 mission。
|
||
//
|
||
// 判据用**跨越**而不是 presenter 的 `config_poll_due(ticks)`(整除):两个时钟的相位不同
|
||
// (帧节拍按 33 ms 累加、空闲心跳按 30 拍累加),整除判定一旦错过一次就**再也命中不了**
|
||
// ——实测 ticks=137 之后每拍 +30 永远不整除,配置轮询静默死掉。间隔真源仍是
|
||
// `CONFIG_POLL_TICKS`(30 拍 × 33 ms ≈ 1 秒)。截图通道不轮询(D30 可复现)。
|
||
let poll_due = self.ticks >= self.config_due_at;
|
||
if poll_due {
|
||
// 追到「当前 ticks 之后的第一个间隔点」,避免心跳把相位拖飞
|
||
self.config_due_at = self.ticks + CONFIG_POLL_TICKS;
|
||
}
|
||
let reloaded =
|
||
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
|
||
if reloaded {
|
||
eprintln!("[settings] 配置文件变了,重读主题");
|
||
self.apply_config_theme();
|
||
}
|
||
|
||
if self.view_dirty {
|
||
self.refresh_view();
|
||
}
|
||
|
||
// 取证开关(`ULL_TICK_TRACE=1`):每跨过 30 拍(≈1 秒)打一行。
|
||
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
|
||
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
|
||
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
|
||
eprintln!(
|
||
"[tick] n={} frames={} refreshes={} clock={}",
|
||
self.ticks,
|
||
self.frames,
|
||
self.refreshes,
|
||
if self.needs_frames() {
|
||
"frames"
|
||
} else {
|
||
"idle-1hz"
|
||
}
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 页面消息:导航走路由栈,动作翻成 `AppCommand`。
|
||
fn on_page(&mut self, message: pages::Message) {
|
||
match message {
|
||
pages::Message::Navigate(route) => {
|
||
self.state.navigate(route);
|
||
self.view_dirty = true;
|
||
self.refresh_view();
|
||
}
|
||
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
|
||
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
|
||
// 还没接后端的占位动作:只打日志(哪几个、为什么,见 pages 模块文档的偏离登记)
|
||
pages::Message::Action(action) => {
|
||
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。
|
||
fn dispatch(&mut self, command: AppCommand) {
|
||
self.backend.dispatch(command);
|
||
// 观察窗:命令发出后 3 秒内保持帧订阅,等后端把第一条事件交回来
|
||
// (真 host 是异步的,派发瞬间状态还没变;不设这个窗口,空闲态的帧订阅永远不会打开)。
|
||
self.watch_until = Some(Instant::now() + WATCH_WINDOW);
|
||
self.pump();
|
||
}
|
||
|
||
/// 抽干事件队列并逐条 `apply`(**唯一**的状态写入口)。
|
||
pub(super) fn pump(&mut self) {
|
||
let events = self.backend.drain_events();
|
||
if events.is_empty() {
|
||
return;
|
||
}
|
||
for event in events {
|
||
self.state.apply(event);
|
||
}
|
||
self.view_dirty = true;
|
||
self.auto_select();
|
||
self.refresh_view();
|
||
}
|
||
|
||
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
|
||
///
|
||
/// 只做一次:用户点「返回」回到列表后,再刷新实例列表也不会把他弹回详情页。
|
||
/// 选中这件事走的是 `AppState::navigate`(路由栈),不是本地选中字段——于是「当前选中」
|
||
/// 永远等于「路由栈顶」,用户点返回时栈语义自然成立。
|
||
fn auto_select(&mut self) {
|
||
if self.auto_selected || self.state.route() != &Route::Instances {
|
||
return;
|
||
}
|
||
let Some(first) = self
|
||
.state
|
||
.tiles()
|
||
.first()
|
||
.map(|cell| cell.instance.id.clone())
|
||
else {
|
||
return;
|
||
};
|
||
self.auto_selected = true;
|
||
self.state.navigate(Route::Detail(first));
|
||
self.view_dirty = true;
|
||
}
|
||
|
||
/// 截图回来了:落盘 + 打一行取证日志,然后退出。
|
||
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
|
||
let Some(path) = self.options.screenshot.clone() else {
|
||
return Task::none();
|
||
};
|
||
match crate::shot::save(screenshot, &path) {
|
||
Ok(summary) => {
|
||
eprintln!("{}", summary.describe());
|
||
eprintln!(
|
||
"[shot] screen={} theme={} renderer={} route={:?}",
|
||
self.options.screen.name(),
|
||
crate::shot::flavor_slug(&self.ui),
|
||
self.options.backend.note(),
|
||
self.state.route()
|
||
);
|
||
}
|
||
Err(error) => eprintln!("[shot] 失败:{error}"),
|
||
}
|
||
|
||
iced::exit()
|
||
}
|
||
}
|
||
|
||
/// 空闲心跳的一拍:睡 1 秒再发 [`Message::Heartbeat`]。
|
||
///
|
||
/// 用 `Task::perform` + `std::thread::sleep` 而不是 `iced::time::every`:后者只在 tokio/smol
|
||
/// 后端提供(本 crate 的执行器是 thread-pool,Cargo.toml 不在 scope)。代价是每拍让线程池里
|
||
/// 一个线程阻塞 1 秒——按 1 Hz 算,对线程池(默认 = CPU 数)是可接受的占用。
|
||
fn heartbeat() -> Task<Message> {
|
||
Task::perform(async { std::thread::sleep(IDLE_TICK) }, |()| {
|
||
Message::Heartbeat
|
||
})
|
||
}
|