From 6b66adff4472d9bf00f0cbb9a798943239aaa77c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 15:36:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M90=20r3=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E7=A9=BA=E9=97=B2=201Hz=20=E5=BF=83=E8=B7=B3=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E9=85=8D=E7=BD=AE=E7=83=AD=E5=88=87=20+=20=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E8=BD=A8=E5=8F=A3=E5=BE=84=E8=AE=A2=E6=AD=A3=20+=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 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 逐字节相同(视觉零变化)。 --- crates/app-iced/src/app/message.rs | 9 ++- crates/app-iced/src/app/state.rs | 44 ++++++++++-- crates/app-iced/src/app/subscription.rs | 27 +++++-- crates/app-iced/src/app/tests.rs | 92 ++++++++++++++++++++++-- crates/app-iced/src/app/update.rs | 94 +++++++++++++++++++++---- crates/app-iced/src/pages/mod.rs | 10 ++- crates/app-iced/src/pages/model.rs | 2 +- crates/app-iced/src/pages/tests.rs | 13 ++-- crates/app-iced/src/shell/mod.rs | 5 +- 9 files changed, 254 insertions(+), 42 deletions(-) diff --git a/crates/app-iced/src/app/message.rs b/crates/app-iced/src/app/message.rs index cc4e6af..9ac2a06 100644 --- a/crates/app-iced/src/app/message.rs +++ b/crates/app-iced/src/app/message.rs @@ -8,7 +8,7 @@ //! | 来源 | 变体 | 落点 | //! |---|---|---| //! | 窗口/框架 | `Boot` / `WindowReady` / `Window` / `Frame` / `Captured` / 三个 caption | [`super::update`] 直接处理 | -//! | **时间** | `Frame` + 33 ms 时间闸门 | presenter 的 TICK 节拍:`backend.tick` + `drain_events` + `apply` | +//! | **时间** | `Frame`(忙碌,33 ms 闸门)+ `Heartbeat`(空闲,1 Hz) | presenter 的 TICK 节拍:`backend.tick` + `drain_events` + `apply` | //! | **页面** | `Page(pages::Message)` | 导航 → `AppState::navigate`;启动/停止 → `AppCommand` | use iced::{window, window::Direction}; @@ -29,8 +29,13 @@ pub enum Message { WindowReady(Option), /// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。 SystemTheme(iced::theme::Mode), - /// 一帧画完(只有截图模式才订阅)。 + /// 一帧画完。**只在需要 33 ms 节拍时订阅**:截图通道 / 有 Busy-Running 实例 / 有未过期 + /// toast / 派发后的 3 s 观察窗(见 `subscription::needs_frames`)。 Frame, + /// **空闲态**的 1 Hz 心跳:`Task::perform(sleep(1s))` 的自续链,用来替代空闲时的帧循环 + /// (M90 r3 / P2-2)。它保证 `theme::config_poll_due` 仍按「30 × 33 ms ≈ 1 秒」的 + /// presenter 语义跑,外部 CLI/AI 改配置后主题 1 秒内生效。 + Heartbeat, /// 窗口事件(M90:`Resized` 决定布局档位,600 / 840 两条断点靠它生效)。 Window(window::Event), /// 页面消息(导航 / 启动 / 停止 / 占位动作)。 diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 2a2e646..e7c491f 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -8,9 +8,10 @@ //! | `bridge: SettingsBridge` | [`SettingsBridge::open`](unlucky_presenter::settings_bridge::SettingsBridge::open) | 读一次外观/主题,之后每 N 拍问「文件变了吗」 | //! | `state: AppState` | [`AppState::new`](unlucky_presenter::state::AppState::new) | **唯一**的状态写入口(`apply` / `navigate`) | //! -//! `view: pages::Model` 是**展示转换的缓存**:presenter 快照 → 页面要画的东西,每拍重建一次 -//! (presenter 文档的原话:「`self.view = ViewModel::from(&self.state)`」)。缓存成字段是为了让 -//! `view(&self)` 能只借 `&self`——iced 的 `view` 签名不允许返回一棵借用局部变量的树。 +//! `view: pages::Model` 是**展示转换的缓存**:presenter 快照 → 页面要画的东西。 +//! 它在**状态真的变了**时重建([`App::view_dirty`] 门控,M90 r2/P2-1 起),不是每拍重建—— +//! 重建要把 N 个实例的名字/版本串克隆一遍,30 Hz 空转没有意义。缓存成字段是为了让 +//! `view(&self)` 能只借 `&self`:iced 的 `view` 签名不允许返回一棵借用局部变量的树。 use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::OnceLock; @@ -31,11 +32,25 @@ use crate::theme::{Families, Ui}; use super::WINDOW_SIZE; -/// presenter 的 TICK 节拍(33 ms ≈ 30 fps)。 +/// presenter 的 TICK 节拍(33 ms ≈ 30 fps)——**忙碌态**的节拍。 /// /// 与 Slint 版的 `TICK_MS` 同值:`backend.tick(..)` 的时间推进、toast 过期、 /// `theme::config_poll_due` 的计数都按这个节拍走,两个 UI 版的时间语义因此一致。 -pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(33); +pub(super) const TICK_MS: u64 = 33; + +/// [`TICK_MS`] 的 `Duration` 形态。 +pub(super) const TICK: std::time::Duration = std::time::Duration::from_millis(TICK_MS); + +/// **空闲态**的低频心跳(1 Hz,M90 r3 / P2-2)。 +/// +/// 空闲时不能订阅 `window::frames()`:那条订阅会自持重绘循环(实测软件渲染约 500 帧/秒), +/// 而空闲时唯一需要的时钟是「偶发一次」——外部 CLI / AI 工具改 `launcher.json` 后的热切 +/// (presenter 的 `theme::config_poll_due` 语义是 30 × 33 ms ≈ 1 秒一拍)。 +/// +/// 实现用的是 `Task::perform(async { sleep(1s) })` 的自续链:[`App::heartbeat`]。它每秒只唤醒 +/// 一次、只触发**一帧**重绘,而 `frames()` 是每秒几百帧。为什么不用 `iced::time::every`: +/// 它挂在 tokio/smol 后端下,本 crate 的执行器是 thread-pool(Cargo.toml 不在 scope)。 +pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(1); /// 派发指令后保持帧订阅的观察窗(见 [`App::watch_until`])。 /// @@ -61,8 +76,15 @@ pub struct App { pub(super) bridge: SettingsBridge, /// presenter 的全局状态(唯一写入口是 `apply`)。 pub(super) state: AppState, - /// 已推进的拍数(`theme::config_poll_due` 的输入)。 + /// 已推进的拍数(`theme::config_poll_due` 的输入)。单位是 **33 ms 一拍**,不是调用次数。 pub(super) ticks: u64, + /// 下一次「比对配置文件 mtime」的 tick 号(见 `update::advance` 的注释)。 + pub(super) config_due_at: u64, + /// 设置文件里的主题模式(`auto` / `light` / `dark`)。 + /// + /// 用来定优先级:`--theme` > 配置文件 > 系统主题。没有它,`Message::SystemTheme` 会无条件 + /// 盖掉配置文件的显式主题(M90 r3 修)。 + pub(super) theme_mode: ThemeMode, /// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。 pub(super) last_tick: Instant, /// 窗口宽度(布局档位的判据;交互模式跟着 resize 事件走)。 @@ -74,6 +96,9 @@ pub struct App { pub(super) view_dirty: bool, /// 「刚派发过指令,等后端回事件」的观察窗(P2-1)。 /// + /// 空闲态**不订阅帧事件**(只有 1 Hz 的 `heartbeat`),所以派发之后必须有这段短窗口让 + /// 33 ms 节拍跑起来,否则真 host 的异步事件永远抽不出来。 + /// /// 空闲态不订阅帧事件(不点亮窗口重绘),但派发之后必须有一段短窗口保持节拍, /// 否则真 host 的异步事件永远抽不出来(`LaunchInstance` 之后状态还是 Idle ⇒ 帧订阅永不开启)。 pub(super) watch_until: Option, @@ -115,6 +140,7 @@ impl App { bridge: SettingsBridge, ) -> Self { let width = WINDOW_SIZE.width; + let theme_mode = bridge.load().1; let view = pages::Model::new( pages::Screen::Instances, width, @@ -129,6 +155,8 @@ impl App { bridge, state: AppState::new(), ticks: 0, + config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS, + theme_mode, last_tick: Instant::now(), width, view, @@ -181,7 +209,9 @@ impl App { if self.has_screenshot() || self.options.theme.is_some() { return; } - match self.bridge.load().1 { + let mode = self.bridge.load().1; + self.theme_mode = mode; + match mode { ThemeMode::Light => self.set_flavor(SchemeFlavor::Light), ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark), ThemeMode::Auto => {} diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index 27b5a58..bb6e8f0 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -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; diff --git a/crates/app-iced/src/app/tests.rs b/crates/app-iced/src/app/tests.rs index 27277fe..37823ed 100644 --- a/crates/app-iced/src/app/tests.rs +++ b/crates/app-iced/src/app/tests.rs @@ -101,13 +101,19 @@ fn frames_are_only_requested_in_screenshot_mode() { // --------------------------------------------------------------- P2-1 按需节拍 -/// 空闲态**不要时钟**:没有忙碌实例、没有 toast、没有观察窗 ⇒ 不订阅帧事件。 +/// 空闲态**不要每帧节拍**(但要有 1 Hz 心跳)。 /// -/// 抓得住:`needs_frames()` 的三条触发条件(这也是 `subscription()` 的唯一判据)—— -/// 谁把「无条件订阅帧」改回来,这条立刻红。 +/// 意图与 r2 那条 `an_idle_app_asks_for_no_clock` 一致:空闲时不许按帧循环跑(那会以每秒几百帧 +/// 的速度重绘整窗),改成「低频心跳」。断言拆成两半: +/// +/// 1. `needs_frames()` 为假 ⇒ **不订阅** `window::frames()`; +/// 2. 空闲时钟的周期 = [`IDLE_TICK`] = 1 s,且心跳每拍推进的 tick 预算 = 30(= 1 s / 33 ms), +/// 这样 `theme::config_poll_due`(30 拍)仍是「约 1 秒比对一次 mtime」。 +/// +/// 抓得住:有人把「无条件订阅帧」改回来(第 1 半红);有人把心跳周期改快/改慢(第 2 半红)。 /// 抓不住:真实窗口的重绘次数(那要 `ULL_TICK_TRACE=1` 跑一次交互模式看日志,见报告)。 #[test] -fn an_idle_app_asks_for_no_clock() { +fn an_idle_app_uses_a_slow_heartbeat_instead_of_frames() { let app = headless(screenshot_options(), SchemeFlavor::Light); assert!(app.needs_frames(), "截图通道永远要帧(第 3 帧落盘)"); @@ -118,9 +124,9 @@ fn an_idle_app_asks_for_no_clock() { "Boot 刚派发过 RefreshAll:3 秒观察窗内要节拍(等真 host 的首批事件)" ); - // 观察窗过期、也没有忙碌实例 ⇒ 空闲态不再要时钟 + // 观察窗过期、也没有忙碌实例 ⇒ 空闲态不订阅帧事件(改走 1 Hz 心跳) idle.watch_until = None; - assert!(!idle.needs_frames(), "空闲交互态:不订阅帧事件"); + assert!(!idle.needs_frames(), "空闲交互态:不订阅帧事件(只有心跳)"); // 派发之后观察窗重新打开 let _ = idle.update(Message::Page(pages::Message::Navigate(Route::Detail( @@ -139,6 +145,36 @@ fn an_idle_app_asks_for_no_clock() { ); } +/// 心跳的**节奏**:1 Hz,且每拍折算 30 个 33 ms 单位 ⇒ `config_poll_due` ≈ 每秒一次。 +/// +/// 抓得住:心跳周期常量、以及 `advance()` 里「按流逝时间折算 ticks」这条记账(改回 +/// `ticks += 1` 会让空闲态的配置轮询变成 30 秒一次,这条就红)。 +/// 抓不住:`Task::perform` 那条自续链真的每秒触发一次(那是运行期 trace 的事,见报告)。 +#[test] +fn the_idle_heartbeat_budgets_one_config_poll_per_second() { + use unlucky_presenter::theme::config_poll_due; + + assert_eq!( + super::state::IDLE_TICK, + std::time::Duration::from_secs(1), + "空闲心跳周期必须是 1 s" + ); + + let mut app = headless(Options::default(), SchemeFlavor::Light); + let _ = app.update(Message::Boot); + app.watch_until = None; + + // 一次空闲心跳(1 s)之后:ticks 增加 30,且正好落在 config 轮询点上 + let before = app.ticks; + app.advance(super::state::IDLE_TICK); + assert_eq!(app.ticks - before, 30, "1 s 应折算 30 个 33 ms 拍"); + assert!( + config_poll_due(app.ticks), + "空闲心跳每拍都要落在 config 轮询点上(ticks={})", + app.ticks + ); +} + /// 空闲态每拍**不再重建视图模型**(P2-1 的另一半:`refresh_view` 的 30 Hz × N 实例白克隆)。 /// /// 抓得住:把 `advance()` 里的 `if self.view_dirty` 去掉(改回无条件 `refresh_view()`)时, @@ -190,3 +226,47 @@ fn screenshot_mode_ignores_the_settings_file_theme() { app.apply_config_theme(); assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行"); } + +/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。 +/// +/// r2 之前 `Message::SystemTheme` 无条件 `set_flavor`,于是「配置文件写 dark、系统是浅色」会 +/// 出现 `light -> dark -> light` 的一次抖动、配置永远不生效(r3 做 P2-2 的配置热切实测时暴露 +/// 出来,一并修)。 +/// +/// 抓得住:系统主题不许盖掉显式配置/命令行给出的主题(三条分支各一条断言)。 +/// 抓不住:真实桌面切换系统主题时的事件时序(只有消息级验证)。 +#[test] +fn the_system_theme_never_overrides_an_explicit_one() { + use unlucky_presenter::prefs::ThemeMode; + + // ① auto(内存桥默认):系统主题说了算 + let mut auto = headless(Options::default(), SchemeFlavor::Light); + assert_eq!(auto.theme_mode, ThemeMode::Auto); + let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark)); + assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统"); + + // ② 配置文件显式 dark:系统给浅色也不许改 + let mut pinned = headless(Options::default(), SchemeFlavor::Dark); + pinned.theme_mode = ThemeMode::Dark; + let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light)); + assert_eq!( + pinned.ui().name(), + "ull-m3-dark", + "配置文件说 dark,系统说 light:不许改" + ); + + // ③ 命令行 `--theme` 优先级最高 + let mut explicit = headless( + Options { + theme: Some(SchemeFlavor::Dark), + ..Options::default() + }, + SchemeFlavor::Dark, + ); + let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light)); + assert_eq!( + explicit.ui().name(), + "ull-m3-dark", + "--theme 优先级最高,系统主题不许盖" + ); +} diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 6e46eec..8ee14fc 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -5,7 +5,7 @@ //! ```text //! 用户动作 ──dispatch(AppCommand)──► backend ──► drain_events() ──► AppState::apply ──► 视图模型 //! ▲ ▲ -//! └── pages::Message(导航/启动/停止)──────────────────┘ Frame + 33 ms 时间闸门 +//! └── pages::Message(导航/启动/停止)──────────────────┘ 忙碌 33 ms / 空闲 1 Hz //! ``` //! //! 三条纪律: @@ -13,7 +13,17 @@ //! 1. **不做乐观回显**:点了「Play」不改本地状态,等 `InstanceRuntimeChanged` 事件回来才变 //! (否则事件与本地状态打架时,磁贴与进度条会各说一套); //! 2. **状态只有一个写入口**:`AppState::apply`(事件)与 `AppState::navigate`(路由); -//! 3. **每拍重建视图模型**:`refresh_view()` 是快照 → 页面的唯一转换点。 +//! 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}; @@ -21,13 +31,14 @@ 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_due; +use unlucky_presenter::theme::CONFIG_POLL_TICKS; use crate::pages; use super::message::{route_of, Message}; -use super::state::{App, TICK, WATCH_WINDOW}; +use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW}; use super::CAPTURE_AT_FRAME; impl App { @@ -45,10 +56,16 @@ impl App { iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light, }; eprintln!("[theme] 系统主题 = {mode:?}"); - self.set_flavor(flavor); + // 优先级:`--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; @@ -116,9 +133,26 @@ impl App { 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`] 帧出图。 /// /// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢, @@ -153,13 +187,27 @@ impl App { self.state .toasts .expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)); - self.ticks += 1; + // `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。 - // 截图通道不轮询(D30 可复现),所以整条判断短路在 `has_screenshot` 上。 - let reloaded = !self.has_screenshot() - && config_poll_due(self.ticks) - && self.bridge.reload_if_changed().is_some(); + // + // 判据用**跨越**而不是 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(); @@ -169,15 +217,20 @@ impl App { self.refresh_view(); } - // 取证开关(`ULL_TICK_TRACE=1`):每 30 拍打一行。**空闲态应当打完一阵就彻底安静** - // —— 那正是「不再订阅帧事件」的可观测形式(没有 Frame ⇒ 这里根本不会被调用)。 - if self.ticks.is_multiple_of(30) && std::env::var_os("ULL_TICK_TRACE").is_some() { + // 取证开关(`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={} frames_wanted={}", + "[tick] n={} frames={} refreshes={} clock={}", self.ticks, self.frames, self.refreshes, - self.needs_frames() + if self.needs_frames() { + "frames" + } else { + "idle-1hz" + } ); } } @@ -266,3 +319,14 @@ impl App { 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 + }) +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 20e516e..2f2b00f 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -5,7 +5,14 @@ //! | 阶段 | 交付 | 判据 | //! |---|---|---| //! | M84 | **布局系统**(档位/版面/列宽)+ 四页占位视觉 | 下面的版面阈值表 | -//! | **M90(本文件当前状态)** | ①视图模型 [`model`] ②导航轨换成真组件并接上 `presenter::router::Route` ③**实例页整页接真组件 + 真数据** | 「接真组件后版面约束一条没破」(见 `pages/tests.rs` 的数值断言) | +//! | **M90(本文件当前状态)** | ①视图模型 [`model`] ②导航项接上 `presenter::router::Route` ③**实例页整页接真组件 + 真数据** | 「接真组件后版面约束一条没破」(见 `pages/tests.rs` 的数值断言) | +//! +//! **导航轨归属(M90 r3 订正,复审 P2-1)**:产品路径(`app/view.rs`)走 +//! `shell::view_with_content`,导航轨由 **`shell/nav.rs` 的手搓实现**画——它与 M78 组件几何同值 +//! (宽 88、药丸 56×32),复审逐像素量过「r1 产品图(真组件 rail)vs r2 产品图(shell rail) +//! 在 y≥48 区域差异为 0」;组件版的 [`rail`] 现在只服务 `pages::app`(`pages_demo` 出口图、 +//! harness)与单测。为什么不是「把 rail 挪进内容槽位」:那需要把 `shell` 的消息类型泛型化/映射, +//! 成本远大于收益(见偏离表 #10)。 //! //! 实例页之外的三页**仍是 M84 的占位视觉**(下载/日志/设置),但它们已经: //! 走同一个 `Message` → `update` → `Route` 通道,导航轨点得动、路由栈按 `presenter::router` 的语义走。 @@ -58,6 +65,7 @@ //! | 6 | 实例页「未选中详情」空态**没有出图** | 演示数据固定选中第 1 条;空态代码路径在(`detail_empty`) | D30 状态矩阵补齐时补一张,不必重跑全套 | //! | 7 | ~~标题栏仍是 48px 留白带~~ → **已于 M90 r2 修**:产品页现在走 `shell::view_with_content`,标题栏 / 拖动热区 / 两条缩放热区 / 三个 caption 键全在 | r1 的实质问题是**交互面缺失**(无边框窗口 + 产品页没有任何窗口装饰 ⇒ 拖不动、缩不了、点不到关闭),不是「少画了一条栏」 | ✅ 已收口(见下) | //! | 8 | 实例页的「New instance / Import」仍是**没有后端的按钮**(M90) | presenter 没有「打开新建向导」的指令(`wizard` 只有状态机,`AppCommand` 里无对应项),已报 finding | 向导 mission 补 `AppCommand::OpenWizard` 之后 | +//! | 10 | **两套 rail 各测各的**(M90 r3):产品走 `shell/nav.rs`,组件版只给 harness/单测 | r2 把产品页接进真外壳后,rail 自然落到 `shell/nav.rs`(像素等价,见上)。但两边的守门是分开的:`shell/nav.rs` 自带 indicator 56×32 的单测,`pages/tests.rs` 守的是组件 rail 的 88 ⇒ **将来哪边先漂没人拦**。统一要泛型化 shell 的消息类型,成本大于收益 | 谁做「rail 单一实现」的 mission(已在报告登记);在那之前任何改 rail 几何的改动都要两处一起改并各自跑守门 | //! | 9 | 实例页的正文段落只在**演示数据**里出现(M90) | 真快照里没有「正文描述」这种字段;M84 那段 361 字符的 `NOTE` 是给行宽测量当样本的,不能凭空造到真页面里 | i18n / 实例详情 mission 补真实描述字段 | //! //! ### 偏离 #7 的收口记录(M90 r2 / P1-1) diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index 7da6ff1..f0261e8 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -6,7 +6,7 @@ //! //! | 数据源 | 谁转 | 用途 | //! |---|---|---| -//! | presenter 的 `AppState` 快照 | `app/view.rs`(`From<&AppState>`) | 产品运行时的真页面 | +//! | presenter 的 `AppState` 快照 | `app/view.rs` 的自由函数 `model(&AppState, width, forced)` | 产品运行时的真页面 | //! | `pages/instances/data.rs` 的演示条目 | [`crate::pages::demo`] | M84 骨架图的可复现基线、`pages_demo` example | //! //! 如果页面直接读 `InstanceSummary`,演示数据就得先伪造成 `InstanceSummary`(含 core 语义字段), diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs index 5129a71..c6b088f 100644 --- a/crates/app-iced/src/pages/tests.rs +++ b/crates/app-iced/src/pages/tests.rs @@ -84,7 +84,8 @@ fn the_width_comparison_really_changes_the_layout() { /// /// **抓得住**:`frame_metrics` 的 `column`(页面唯一的列宽来源)不会越界; /// **抓不住**:页面自己绕过 metrics 写死宽度(那条由 `tests/no_hardcoded_tokens.rs` 的 -/// 「`.width(` 后面不许跟数字字面量」守门——注意它扫的是 `src/shell` + `src/pages`)。 +/// 「`.width(` 后面不许跟数字字面量」守门——它扫的是 `src/shell` + `src/pages` + `src/app` +/// (M90 r2/P3-3 把 `app` 加进去了)。 #[test] fn every_page_any_width_stays_under_the_measure_cap() { let cap = measure_cap_px(); @@ -119,10 +120,14 @@ fn the_three_frozen_numbers_are_unchanged() { assert_eq!(WidthClass::of(840.0), WidthClass::Expanded); } -/// 导航轨用的**真组件**(M78 的 `NavigationRail`)声明的宽度必须与布局系统同值。 +/// **组件版**导航轨(M78 的 `NavigationRail`)声明的宽度必须与布局系统同值。 /// -/// **抓得住**:`NavPaneGeometry::rail().width` == `layout::rail_width()` == 88(组件与布局系统没分叉); -/// **抓不住**:容器圆角/药丸摆放的像素级正确性(组件自己的 probe 管)。 +/// 注意这不是产品路径的实现(M90 r3 订正):产品页的 rail 由 `shell/nav.rs` 画,组件版只服务 +/// `pages::app`(harness 出口图)与单测。这条守的是「组件与布局系统没分叉」,**不是**「产品 rail +/// 没问题」——两套实现的漂移风险见 `pages/mod.rs` 偏离表 #10。 +/// +/// **抓得住**:`NavPaneGeometry::rail().width` == `layout::rail_width()` == 88; +/// **抓不住**:容器圆角/药丸摆放的像素级正确性(组件自己的 probe 管),也抓不住 `shell/nav.rs`。 #[test] fn the_real_rail_agrees_with_the_layout_system() { let ui = ui(); diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index 070ff0f..f4be16b 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -44,8 +44,9 @@ use crate::theme::Ui; /// /// 分工写死: /// -/// - 本模块负责:标题栏(含 8px 级拖动热区与三个 caption 键)、东/南两条缩放热区、导航轨、 -/// 根容器底色; +/// - 本模块负责:标题栏(拖动面 = **整条 48px 栏里从窗口左缘到第一个 caption** 的 `mouse_area`, +/// 没有单独的 8px 热区;三个 caption 键各 46×48)、东/南两条缩放热区(各 `Space::X1` = 4px)、 +/// 导航轨、根容器底色; /// - 调用方负责:`content`(页面内容区)。它拿到的可用宽度是 `窗口宽 − rail 88`, /// 而页面自己的 `layout::frame_metrics` 仍以**窗口宽**为输入(它内部会再减 rail 与页边距), /// 两边的账不会算串。