//! `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 crate::renderer::Kind; use super::message::{route_of, Message}; use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW}; use super::CAPTURE_AT_FRAME; mod agent; mod download; mod page_messages; mod settings; impl App { /// 一条消息进来,改状态并返回后续任务。 pub(super) fn update(&mut self, message: Message) -> Task { match message { Message::Boot => self.on_boot(), Message::WindowReady(id) => { self.window = id; // M93:整窗 α 要 Win32 句柄,而 iced 只给 `window::Id`。`window::run` 是唯一能把 // `&dyn Window`(实现了 `HasWindowHandle`)拿出来的入口。 match id { Some(id) => { window::run(id, super::window_alpha::probe_hwnd).map(Message::WindowHandle) } None => Task::none(), } } Message::WindowHandle(hwnd) => { self.hwnd = hwnd; match hwnd { Some(_) => self.apply_window_alpha("boot"), None => { eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)"); self.log_appearance("boot", None); } } 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.height = size.height; self.view_dirty = true; self.refresh_view(); } Task::none() } Message::Page(message) => self.on_page(message), Message::Agent(message) => self.on_agent(message), // 外壳导航轨(`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 { let mut tasks = vec![window::latest().map(Message::WindowReady)]; // 主题优先级:显式 `--theme` / `--screenshot-theme` > 设置文件 > 系统。 // 截图通道**不读设置文件**(D30 可复现:同一份代码在两台机器上要出同一张图)。 self.apply_config_theme(); // M93 外观取证开关(`ULL_APPEARANCE_*`)+ 启动那一行数值日志。 // 必须在 `refresh_view` 之前:设置页要画的就是这份状态。 self.appearance.apply_env(); self.rebuild_ui(); self.log_appearance("boot", None); // 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档) if self.options.wants_system_theme() { tasks.push(iced::system::theme().map(Message::SystemTheme)); } // M104:助手栏的启动尾巴(窗口尺寸 adopt + 「拆出档直接落到独立页」)在 update/agent.rs。 // **必须在 RefreshAll 之前**:那次 dispatch 会 `pump → auto_select`,把路由拉到实例详情, // 而 `sync_agent_route` 看到「不在 Agent 页却还是 Detached」就会把档位收回来——顺序反了 // 就会出现「`ULL_AGENT_RAIL=detached` 启动后停在实例页」(r2 实测踩到)。 self.boot_agent(); // 「重新扫描实例目录 + 全部页面快照」:真 host 在这里发首批事件,mock 在构造时已预置 self.dispatch(AppCommand::RefreshAll); // 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope, // 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。 if let Some(route) = env_start_route() { eprintln!("[boot] ULL_START_ROUTE → {route:?}"); self.state.navigate(route); // 直达某一页时不要再被「自动选中第一条实例」拉回详情页。 self.auto_selected = true; } 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 { 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 { 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(); // M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。 self.reload_appearance(); } 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" } ); } } /// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。 pub(super) 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 { let Some(path) = self.options.screenshot.clone() else { return Task::none(); }; match crate::shot::save(screenshot, &path) { Ok(summary) => { eprintln!("{}", summary.describe()); // M97:打**实际**后端名(`wgpu`/`tiny-skia`),不是请求值——`renderer=auto` 曾经出现在每张验收图的日志里。 eprintln!( "[shot] screen={} theme={} renderer={} route={:?}", self.options.screen.name(), crate::shot::flavor_slug(&self.ui), super::backend::effective_kind().map_or("?", Kind::name), 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 { Task::perform(async { std::thread::sleep(IDLE_TICK) }, |()| { Message::Heartbeat }) } /// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `logs`)。 /// /// 为什么是环境变量:`crates/app-iced/src/cli.rs` 不在 M93 的 scope,而外观矩阵必须在**设置页**上 /// 出图。语义与 `ULL_APPEARANCE_*` / `ULL_TICK_TRACE` 一致:只影响这一次进程。 fn env_start_route() -> Option { match std::env::var("ULL_START_ROUTE").ok()?.trim() { "instances" => Some(Route::Instances), "download" => Some(Route::Download), "logs" => Some(Route::Logs), "settings" => Some(Route::Settings), // M104:AI 独立页也要能直达(三态矩阵的 Detached 那张) "agent" => Some(Route::Agent), _ => None, } } #[cfg(test)] mod download_wiring;