//! 应用状态:**只放字段与构造**,不放消息分支(那是 [`super::update`] 的事)。 //! //! ## presenter 三件(`crates/presenter/src/lib.rs` crate 文档 §4) //! //! | 字段 | 谁给 | 干什么 | //! |---|---|---| //! | `backend: Box` | [`build_backend`](unlucky_presenter::backend::build_backend) | 派发 [`AppCommand`](unlucky_presenter::dto::AppCommand)、收 [`AppEvent`](unlucky_presenter::dto::AppEvent) | //! | `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 快照 → 页面要画的东西。 //! 它在**状态真的变了**时重建([`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; use std::time::Instant; use iced::window; use unlucky_design::color::SchemeFlavor; use unlucky_presenter::backend::{build_backend, config_path, AppOptions}; use unlucky_presenter::dto::Backend; use unlucky_presenter::prefs::ThemeMode; use unlucky_presenter::settings_bridge::SettingsBridge; use unlucky_presenter::state::AppState; use crate::cli::Options; use crate::pages; use crate::theme::{Families, Ui}; use super::WINDOW_SIZE; /// presenter 的 TICK 节拍(33 ms ≈ 30 fps)——**忙碌态**的节拍。 /// /// 与 Slint 版的 `TICK_MS` 同值:`backend.tick(..)` 的时间推进、toast 过期、 /// `theme::config_poll_due` 的计数都按这个节拍走,两个 UI 版的时间语义因此一致。 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`])。 /// /// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。 pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3); /// 进程内「run() 开始」的时刻(首帧耗时基线)。 pub(super) static STARTED: OnceLock = OnceLock::new(); /// 首帧日志只打一次。 pub(super) static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false); /// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。 pub struct App { /// 启动参数(截图/主题/画面开关)。 pub(super) options: Options, /// 字体族(切主题时重建 [`Ui`] 要用)。 pub(super) families: Families, /// 令牌求值器(浅/深两套)。 pub(super) ui: Ui, /// 后端通道(真 host 或 mock)。 pub(super) backend: Box, /// 设置持久化桥。 pub(super) bridge: SettingsBridge, /// presenter 的全局状态(唯一写入口是 `apply`)。 pub(super) state: AppState, /// 已推进的拍数(`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 事件走)。 pub(super) width: f32, /// 展示转换缓存(presenter 快照 → 页面视图模型)。 pub(super) view: pages::Model, /// 视图模型是否过期(P2-1):只有 `apply` / `navigate` / resize / 主题这类**真的改了状态** /// 的事情才置位,`advance()` 每拍只在置位时重建模型(否则 30 Hz × N 实例的白克隆)。 pub(super) view_dirty: bool, /// 「刚派发过指令,等后端回事件」的观察窗(P2-1)。 /// /// 空闲态**不订阅帧事件**(只有 1 Hz 的 `heartbeat`),所以派发之后必须有这段短窗口让 /// 33 ms 节拍跑起来,否则真 host 的异步事件永远抽不出来。 /// /// 空闲态不订阅帧事件(不点亮窗口重绘),但派发之后必须有一段短窗口保持节拍, /// 否则真 host 的异步事件永远抽不出来(`LaunchInstance` 之后状态还是 Idle ⇒ 帧订阅永不开启)。 pub(super) watch_until: Option, /// 窗口句柄(`window::latest()` 之后才有)。 pub(super) window: Option, /// 已画的帧数(截图闸门用)。 pub(super) frames: u32, /// 视图模型重建次数(P2-1 的可观测计数:空闲态 100 拍里必须是 0 增长)。 pub(super) refreshes: u64, /// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。 pub(super) auto_selected: bool, /// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。 pub(super) height: f32, /// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。 pub(super) appearance: pages::appearance::State, /// 设置文件里那 8 个「UI 写入」外观字段的最近快照(写回时只改我们真改了的字段, /// 强调色这类本 mission 不管的字段原样保留)。 pub(super) appearance_prefs: unlucky_presenter::prefs::AppearancePrefs, /// 窗口句柄(`Message::WindowHandle` 之后才有;非 Windows 平台恒 `None`)。 pub(super) hwnd: Option, } impl App { /// 生产构造:按启动参数选后端、打开设置桥。 /// /// **截图通道强制 mock**:出图要可复现(D30),真 host 会按本机盘上的实例清单画面, /// 同一份代码在两台机器上出不了同一张图。交互模式走真 host(数据根解析不出来时 /// [`build_backend`] 自己退回 mock)。 pub(super) fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self { let backend = build_backend(&app_options(&options)); // P2-2:设置桥必须与后端指向**同一个数据根**。早先这里无条件 `SettingsBridge::open()` // (按便携配置解析),于是 `--data-dir X` 会出现「实例来自 X、主题/设置读写默认根」的一进程 // 两个数据根。Slint 版是同一条口径:`app/src/ui_bind.rs` 的 // `Some(root) => SettingsBridge::with_path(config_path(root))`。 let bridge = match &options.data_dir { Some(root) => SettingsBridge::with_path(config_path(root)), None => SettingsBridge::open(), }; Self::with_presenter(options, families, flavor, backend, bridge) } /// 注入式构造(单测用:后端与桥都由调用方给,**不碰磁盘**)。 pub(super) fn with_presenter( options: Options, families: Families, flavor: SchemeFlavor, backend: Box, bridge: SettingsBridge, ) -> Self { let width = WINDOW_SIZE.width; let height = WINDOW_SIZE.height; let theme_mode = bridge.load().1; // 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 3 个只读下层键 // (整窗 α / 标题栏三态 / 配色来源)。M93 之前这两个键在 app 侧**根本读不到**。 // // 截图通道**不读用户配置**(D30:同一份代码在两台机器上要出同一张图):外观一律取出厂值, // 矩阵需要的值由 `ULL_APPEARANCE_*` 显式灌进来。 let (appearance_prefs, appearance_base) = if options.screenshot.is_some() { ( unlucky_presenter::prefs::AppearancePrefs::factory(), unlucky_presenter::prefs::AppearanceBase::factory(), ) } else { (bridge.load().0, bridge.load_base()) }; // 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2): // 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。 let backend_tier = super::backend::effective_backend(options.backend); let appearance = pages::appearance::State::new( &appearance_prefs, &appearance_base, backend_tier, flavor, ); let view = pages::Model::new( pages::Screen::Instances, width, Some(pages::Screen::Instances.nav()), ); let mut app = Self { ui: Ui::new(flavor, families), families, options, backend, bridge, state: AppState::new(), ticks: 0, config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS, theme_mode, last_tick: Instant::now(), width, view, view_dirty: false, watch_until: None, window: None, frames: 0, refreshes: 0, auto_selected: false, height, appearance, appearance_prefs, hwnd: None, }; // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 app.rebuild_ui(); app } /// 本次进程是否走截图通道。 pub(super) fn has_screenshot(&self) -> bool { self.options.screenshot.is_some() } /// 切主题:重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。 /// /// M93 起 `Ui` 有两个来源:配色来源取到了图(①②级)就用解析出的方案,否则用令牌 baseline。 /// 所以这里改叫「重算 `Ui`」:先让外观状态记住新深浅(配色解析要它),再重建。 pub(super) fn set_flavor(&mut self, flavor: SchemeFlavor) { if self.ui.flavor() == flavor && self.appearance.flavor() == flavor { return; } self.appearance.set_flavor(flavor); let previous = self.ui.name(); self.rebuild_ui(); eprintln!( "[theme] {} -> {} (Theme::name 变化触发缓存重算)", previous, self.ui.name() ); } /// 按「配色来源是否取色成功」重建 [`Ui`]。 /// /// 色卡档(③)刻意继续走 `Scheme::baseline`:`resolve_source` 的色卡档返回的是 /// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的 /// 像素**改掉。这条偏离登记在 `pages::appearance` 的偏离表 #4。 pub(super) fn rebuild_ui(&mut self) { // 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一** // 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。 let panel_alpha = self.appearance.effective_panel_alpha(); self.ui = match self.appearance.derived_scheme() { Some(scheme) => { eprintln!( "[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)", self.appearance.palette_level() ); Ui::from_scheme(*scheme, self.families) } None => Ui::new(self.appearance.flavor(), self.families), } .with_panel_alpha(panel_alpha); } /// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。 /// /// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。 pub(super) fn refresh_view(&mut self) { // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 let category = self.view.download.category; self.view = super::view::model(&self.state, self.width, self.options.force_state, category); self.view_dirty = false; self.refreshes += 1; } /// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。 /// /// **截图通道直接跳过**:`--screenshot` 的主题唯一取值口是 `Options::capture_flavor` /// (D30 可复现),读本机 `launcher.json` 会让同一份代码在两台机器上出不同的图。 /// /// `ThemeMode::Auto` 不在这里处理:那要看系统主题(`iced::system::theme()` 的结果在 /// `Message::SystemTheme` 里落地)。 pub(super) fn apply_config_theme(&mut self) { if self.has_screenshot() || self.options.theme.is_some() { return; } 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 => {} } } /// 当前令牌求值器(测试与截图日志用)。 pub(super) fn ui(&self) -> &Ui { &self.ui } } /// 启动参数 → presenter 的后端选项。 fn app_options(options: &Options) -> AppOptions { AppOptions { // `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。 // 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir ` 不会往那个目录写东西。 data_root: options.data_dir.clone(), mock: options.screenshot.is_some(), } } /// 首帧耗时日志(只在第一次 `view()` 打一行)。 pub(super) fn log_first_frame() { if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) { return; } if let Some(started) = STARTED.get() { eprintln!( "[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)", started.elapsed().as_secs_f64() * 1000.0 ); } }