From 3c61a2397e8f9c9c9326f66391b12e629d03c269 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 08:02:45 +0800 Subject: [PATCH] =?UTF-8?q?refactor(app-iced):=20=E6=8B=86=20state.rs(400?= =?UTF-8?q?=20=E8=A1=8C)=20=E4=B8=8E=20update.rs(399=20=E8=A1=8C)=20?= =?UTF-8?q?=E4=B8=BA=E5=8A=9F=E8=83=BD=E5=AD=90=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个文件都贴着 400 行硬线、零余量,M119 要往里加字段与一行 drain。**这一个提交只搬不改** (下一次提交才是改动),搬的位置按功能切: | 原位置 | 现在 | 内容 | |---|---|---| | `app/state.rs` | `state.rs`(172) | 只剩 crate 文档 + 常量 + `struct App` 的字段与注释 | | 同上 | `state/construct.rs` | 两个构造器 + 字段初始化表 | | 同上 | `state/refresh.rs` | `refresh_view`(快照 → 视图模型的唯一转换点) | | 同上 | `state/theme.rs` | `Ui` 重算 / 主题档 / `has_screenshot` / `ui()` | | `app/update.rs` | `update/clock.rs` | 推进一拍 / 派发 / 抽干事件 / 自动选中 | 一处**非字面**的改动:搬进子模块之后 `pub(super)` 的语义从「crate::app」变成「crate::app::state」, 所以这些方法改成 `pub(in crate::app)`——可见范围与搬家前逐字相同,只是写法得跟着模块路径走。 除此之外照抄原文(含原有注释与文档)。 搬完 `cargo check -p unlucky-app-iced --all-targets` 零 error 零 warning。 --- crates/app-iced/src/app/state.rs | 256 ++------------------- crates/app-iced/src/app/state/construct.rs | 146 ++++++++++++ crates/app-iced/src/app/state/refresh.rs | 58 +++++ crates/app-iced/src/app/state/theme.rs | 86 +++++++ crates/app-iced/src/app/update.rs | 117 +--------- crates/app-iced/src/app/update/clock.rs | 134 +++++++++++ 6 files changed, 441 insertions(+), 356 deletions(-) create mode 100644 crates/app-iced/src/app/state/construct.rs create mode 100644 crates/app-iced/src/app/state/refresh.rs create mode 100644 crates/app-iced/src/app/state/theme.rs create mode 100644 crates/app-iced/src/app/update/clock.rs diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index ea33fc0..45f3b7d 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -12,26 +12,32 @@ //! 它在**状态真的变了**时重建([`App::view_dirty`] 门控,M90 r2/P2-1 起),不是每拍重建—— //! 重建要把 N 个实例的名字/版本串克隆一遍,30 Hz 空转没有意义。缓存成字段是为了让 //! `view(&self)` 能只借 `&self`:iced 的 `view` 签名不允许返回一棵借用局部变量的树。 +//! +//! ## 本文件只管「有哪些字段」 +//! +//! M119 起按功能拆出去(`state.rs` 当时正好 400 行、零余量): +//! +//! | 子文件 | 内容 | +//! |---|---| +//! | [`construct`] | 两个构造器 + 字段初始化表 | +//! | [`refresh`] | [`App::refresh_view`](快照 → 视图模型的唯一转换点) | +//! | [`theme`] | `Ui` 重算 / 主题档 / [`App::has_screenshot`] | +//! | [`boot_probe`] | 启动探针(`ULL_*` 取证开关与首帧日志) | use std::time::Instant; use iced::window; -use unlucky_design::color::SchemeFlavor; -use unlucky_presenter::backend::{build_backend, config_path}; use unlucky_presenter::dto::Backend; use unlucky_presenter::prefs::ThemeMode; use unlucky_presenter::settings_bridge::SettingsBridge; use unlucky_presenter::state::AppState; -use unlucky_presenter::toast::ToastKind; use crate::cli::Options; use crate::pages; use crate::theme::{Families, Ui}; use crate::ui::lang::LangState; -use super::WINDOW_SIZE; - /// presenter 的 TICK 节拍(33 ms ≈ 30 fps)——**忙碌态**的节拍。 /// /// 与 Slint 版的 `TICK_MS` 同值:`backend.tick(..)` 的时间推进、toast 过期、 @@ -58,8 +64,10 @@ pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3); mod boot_probe; +mod construct; +mod refresh; +mod theme; -use boot_probe::app_options; pub(crate) use boot_probe::{log_first_frame, STARTED}; /// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。 @@ -162,239 +170,3 @@ pub struct App { /// [`crate::platform::win32::corner::CornerApplied`]。 pub(super) corner_applied: 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()` 给 4 个只读下层键 - // (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。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 mut appearance = pages::appearance::State::new( - &appearance_prefs, - &appearance_base, - backend_tier, - flavor, - ); - // 取证开关(D30):`ULL_SETTINGS_GROUP=<下标>` 直达设置页的某一组(出图要看见语言行)。 - if let Some(index) = - crate::ui::lang::screenshot_settings_group(options.screenshot.is_some()) - { - appearance.selected_group = index; - } - // 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在 - // `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。 - let lang = LangState::boot(&bridge, options.screenshot.is_some()); - let mut view = pages::Model::new( - pages::Screen::Instances, - width, - Some(pages::Screen::Instances.nav()), - ); - view.lang = lang.lang(); - - 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, - lang, - 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, - settings: pages::settings::Groups::default(), - // 取证开关 `ULL_AGENT_*`(三态矩阵 / 空会话)在这里落地;不设变量 = 出厂态 - agent: crate::shell::agent::demo::from_env().unwrap_or_default(), - // 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串 - download_draft: pages::InstallDraft::default(), - install_cancelled_seen: 0, - content: pages::content::PageState::default(), - account_form: pages::account::Form::new(), - account: pages::account::Page::build( - &AppState::new(), - &pages::account::Form::new(), - &mut pages::account::avatar::Cache::default(), - unlucky_i18n::Lang::En, - width, - ), - avatars: pages::account::avatar::Cache::default(), - corner_applied: None, - }; - // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 - app.rebuild_ui(); - // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 - // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 - app.lang.log(); - app.lang.log_cjk(); - 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) { - // **不变式先对齐再算宽度**(M104 / 复审 r1 P2-1):`Route::Agent` ⟺ `RailState::Detached`。 - // 每一次路由变化都会走到这里,所以它是「档位 ↔ 路由」的唯一点;放在算宽度之前, - // 是为了让模型拿到的 `content_width` 与页面头部读到的档位来自同一份状态。 - self.sync_agent_route(); - - // M105:取消安装的提示语在**这里**弹(`refresh_view` 是事件 apply 之后必经的那一处)。 - // 文案由页面层求(`pages::cancelled_toast`),presenter 只发信号 —— - // 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。 - if self.state.install_cancelled != self.install_cancelled_seen { - self.install_cancelled_seen = self.state.install_cancelled; - // M106:提示语跟着**生效语言**走(M105 留的 `view::ui_lang()` 占位已删——它恒英文, - // 中文界面会弹出英文提示,那正是 M105 r1 复审 P2-2 要堵的同屏两语)。 - let text = pages::cancelled_toast(self.lang()); - self.state.toasts.push(ToastKind::Info, text); - } - - // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): - // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 - let category = self.view.download.category; - // 草稿同理(M105):它也是页面自己持有的输入,重建模型时带回去, - // 否则每来一条事件(连安装进度都算)用户填的加载器就被抹回「原版」。 - let draft = self.download_draft.clone(); - // 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104) - let width = self.agent.content_width(self.width); - // M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。 - self.view = super::view::model( - &self.state, - width, - self.options.force_state, - category, - &draft, - self.lang(), - &self.content, - ); - // 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`, - // 皮肤解码走缓存,所以重复重建不重解图)。 - self.rebuild_account_page(width); - 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 - } -} diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs new file mode 100644 index 0000000..585ce9d --- /dev/null +++ b/crates/app-iced/src/app/state/construct.rs @@ -0,0 +1,146 @@ +//! `App` 的两个构造器(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。 +//! +//! | 构造器 | 谁用 | 碰不碰磁盘 | +//! |---|---|---| +//! | [`App::new`](super::App::new) | 生产(`main` → `boot`) | 按启动参数选后端 + 打开设置桥 | +//! | [`App::with_presenter`](super::App::with_presenter) | 单测(后端与桥都由调用方给) | **不碰** | +//! +//! 两个都走 `with_presenter`:生产路径只是先造好后端与桥。**字段初始化表**也在这一个文件里 +//! (构造 = 字段表 + 三件外部依赖),加字段时只改这一处。 + +use iced::window; + +use unlucky_design::color::SchemeFlavor; +use unlucky_presenter::backend::{build_backend, config_path}; +use unlucky_presenter::dto::Backend; +use unlucky_presenter::settings_bridge::SettingsBridge; +use unlucky_presenter::state::AppState; + +use crate::cli::Options; +use crate::pages; +use crate::theme::{Families, Ui}; +use crate::ui::lang::LangState; + +use super::super::WINDOW_SIZE; +use super::boot_probe::app_options; +use super::App; + +impl App { + /// 生产构造:按启动参数选后端、打开设置桥。 + /// + /// **截图通道强制 mock**:出图要可复现(D30),真 host 会按本机盘上的实例清单画面, + /// 同一份代码在两台机器上出不了同一张图。交互模式走真 host(数据根解析不出来时 + /// [`build_backend`] 自己退回 mock)。 + pub(in crate::app) 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(in crate::app) 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()` 给 4 个只读下层键 + // (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。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::super::backend::effective_backend(options.backend); + let mut appearance = pages::appearance::State::new( + &appearance_prefs, + &appearance_base, + backend_tier, + flavor, + ); + // 取证开关(D30):`ULL_SETTINGS_GROUP=<下标>` 直达设置页的某一组(出图要看见语言行)。 + if let Some(index) = + crate::ui::lang::screenshot_settings_group(options.screenshot.is_some()) + { + appearance.selected_group = index; + } + // 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在 + // `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。 + let lang = LangState::boot(&bridge, options.screenshot.is_some()); + let mut view = pages::Model::new( + pages::Screen::Instances, + width, + Some(pages::Screen::Instances.nav()), + ); + view.lang = lang.lang(); + + 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, + lang, + last_tick: std::time::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, + settings: pages::settings::Groups::default(), + // 取证开关 `ULL_AGENT_*`(三态矩阵 / 空会话)在这里落地;不设变量 = 出厂态 + agent: crate::shell::agent::demo::from_env().unwrap_or_default(), + // 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串 + download_draft: pages::InstallDraft::default(), + install_cancelled_seen: 0, + content: pages::content::PageState::default(), + account_form: pages::account::Form::new(), + account: pages::account::Page::build( + &AppState::new(), + &pages::account::Form::new(), + &mut pages::account::avatar::Cache::default(), + unlucky_i18n::Lang::En, + width, + ), + avatars: pages::account::avatar::Cache::default(), + corner_applied: None, + }; + // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 + app.rebuild_ui(); + // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 + // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 + app.lang.log(); + app.lang.log_cjk(); + app + } +} diff --git a/crates/app-iced/src/app/state/refresh.rs b/crates/app-iced/src/app/state/refresh.rs new file mode 100644 index 0000000..118a553 --- /dev/null +++ b/crates/app-iced/src/app/state/refresh.rs @@ -0,0 +1,58 @@ +//! 快照 → 视图模型的重建口径(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。 +//! +//! 全仓只有 [`App::refresh_view`] 这一个「presenter 快照 → 页面要画的东西」的转换点, +//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位, +//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。 + +use unlucky_presenter::toast::ToastKind; + +use crate::pages; + +use super::App; + +impl App { + /// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。 + /// + /// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。 + pub(in crate::app) fn refresh_view(&mut self) { + // **不变式先对齐再算宽度**(M104 / 复审 r1 P2-1):`Route::Agent` ⟺ `RailState::Detached`。 + // 每一次路由变化都会走到这里,所以它是「档位 ↔ 路由」的唯一点;放在算宽度之前, + // 是为了让模型拿到的 `content_width` 与页面头部读到的档位来自同一份状态。 + self.sync_agent_route(); + + // M105:取消安装的提示语在**这里**弹(`refresh_view` 是事件 apply 之后必经的那一处)。 + // 文案由页面层求(`pages::cancelled_toast`),presenter 只发信号 —— + // 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。 + if self.state.install_cancelled != self.install_cancelled_seen { + self.install_cancelled_seen = self.state.install_cancelled; + // M106:提示语跟着**生效语言**走(M105 留的 `view::ui_lang()` 占位已删——它恒英文, + // 中文界面会弹出英文提示,那正是 M105 r1 复审 P2-2 要堵的同屏两语)。 + let text = pages::cancelled_toast(self.lang()); + self.state.toasts.push(ToastKind::Info, text); + } + + // 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数): + // 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。 + let category = self.view.download.category; + // 草稿同理(M105):它也是页面自己持有的输入,重建模型时带回去, + // 否则每来一条事件(连安装进度都算)用户填的加载器就被抹回「原版」。 + let draft = self.download_draft.clone(); + // 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104) + let width = self.agent.content_width(self.width); + // M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。 + self.view = super::super::view::model( + &self.state, + width, + self.options.force_state, + category, + &draft, + self.lang(), + &self.content, + ); + // 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`, + // 皮肤解码走缓存,所以重复重建不重解图)。 + self.rebuild_account_page(width); + self.view_dirty = false; + self.refreshes += 1; + } +} diff --git a/crates/app-iced/src/app/state/theme.rs b/crates/app-iced/src/app/state/theme.rs new file mode 100644 index 0000000..6ecb723 --- /dev/null +++ b/crates/app-iced/src/app/state/theme.rs @@ -0,0 +1,86 @@ +//! 主题与 `Ui` 的重算口径(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。 +//! +//! 三件事在这里:①`Ui` 什么时候重建([`App::rebuild_ui`])②主题档怎么定 +//! ([`App::set_flavor`] / [`App::apply_config_theme`])③读当前求值器([`App::ui`])。 +//! +//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。 + +use unlucky_design::color::SchemeFlavor; +use unlucky_presenter::prefs::ThemeMode; + +use crate::theme::Ui; + +use super::App; + +impl App { + /// 本次进程是否走截图通道。 + pub(in crate::app) fn has_screenshot(&self) -> bool { + self.options.screenshot.is_some() + } + + /// 切主题:重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。 + /// + /// M93 起 `Ui` 有两个来源:配色来源取到了图(①②级)就用解析出的方案,否则用令牌 baseline。 + /// 所以这里改叫「重算 `Ui`」:先让外观状态记住新深浅(配色解析要它),再重建。 + pub(in crate::app) 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(in crate::app) 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); + } + + /// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。 + /// + /// **截图通道直接跳过**:`--screenshot` 的主题唯一取值口是 `Options::capture_flavor` + /// (D30 可复现),读本机 `launcher.json` 会让同一份代码在两台机器上出不同的图。 + /// + /// `ThemeMode::Auto` 不在这里处理:那要看系统主题(`iced::system::theme()` 的结果在 + /// `Message::SystemTheme` 里落地)。 + pub(in crate::app) 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(in crate::app) fn ui(&self) -> &Ui { + &self.ui + } +} diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index d8b5d0b..c81e4b5 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -25,7 +25,7 @@ //! //! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。 -use std::time::{Duration, Instant}; +use std::time::Instant; use iced::{window, Task}; @@ -33,19 +33,19 @@ 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::state::{App, IDLE_TICK, TICK, WATCH_WINDOW}; use super::CAPTURE_AT_FRAME; mod account; mod agent; mod appearance; mod boot_env; +mod clock; mod content; mod download; mod page_messages; @@ -249,117 +249,6 @@ impl App { } } - /// 推进一拍:后端消化时间 → 抽干事件 → 过期 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)); - - // 设备码/授权码的倒计时(M114):等待态每秒都要重画一次,否则「剩 900s」永远不动, - // 用户会以为卡死了——这正是任务里「不得只转圈」的那一条。 - if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting { - self.view_dirty = true; - } - // `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 { diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs new file mode 100644 index 0000000..ac9ef06 --- /dev/null +++ b/crates/app-iced/src/app/update/clock.rs @@ -0,0 +1,134 @@ +//! 节拍:推进一拍 / 派发指令 / 抽干事件(M119 从 `update.rs` 拆出来守 400 行硬线;纯搬不改)。 +//! +//! ## 为什么这三件必须在一起 +//! +//! `advance` → `pump` → `apply` → `refresh_view` 是同一条链:时间推进、事件抽干、状态落地、 +//! 视图重建。拆开看每一段都无害,合起来才是「presenter 的异步事件在 UI 侧怎么被消费」的答案。 +//! +//! ## 两个时钟的相位(M90 r3 / P2-2 的老账,代码里有细节) +//! +//! | 场景 | 时钟 | 周期 | +//! |---|---|---| +//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | +//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | +//! +use std::time::{Duration, Instant}; + +use unlucky_presenter::dto::AppCommand; +use unlucky_presenter::theme::CONFIG_POLL_TICKS; + +use super::super::state::TICK_MS; +use super::*; + +impl App { + /// 推进一拍:后端消化时间 → 抽干事件 → 过期 toast → 偶发重读配置文件。 + /// + /// `elapsed` 是真实流逝时间(毫秒级抖动是正常的:`MockBackend` 的脚本按累计时长走)。 + pub(in crate::app) 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)); + + // 设备码/授权码的倒计时(M114):等待态每秒都要重画一次,否则「剩 900s」永远不动, + // 用户会以为卡死了——这正是任务里「不得只转圈」的那一条。 + if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting { + self.view_dirty = true; + } + // `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(in crate::app) fn dispatch(&mut self, command: AppCommand) { + self.backend.dispatch(command); + // 观察窗:命令发出后 3 秒内保持帧订阅,等后端把第一条事件交回来 + // (真 host 是异步的,派发瞬间状态还没变;不设这个窗口,空闲态的帧订阅永远不会打开)。 + self.watch_until = Some(Instant::now() + WATCH_WINDOW); + self.pump(); + } + + /// 抽干事件队列并逐条 `apply`(**唯一**的状态写入口)。 + pub(in crate::app) 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`(路由栈),不是本地选中字段——于是「当前选中」 + /// 永远等于「路由栈顶」,用户点返回时栈语义自然成立。 + pub(in crate::app) 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; + } +}