Files
Unlucky-Launcher/crates/app-iced/src/app/update.rs
T
AskaEth 6b66adff44 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 逐字节相同(视觉零变化)。
2026-11-02 15:56:59 +08:00

333 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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
})
}