Files
Unlucky-Launcher/crates/app-iced/src/app/state.rs
T
AskaEth a183adc072 refactor(app-iced, presenter): M114 rebase 后守 400 行 —— 四处纯搬移(不动语义、不压注释、不挪断言)
主线六条合入后,四个被我碰过的文件越过 400 行;按模块拆开,只搬不改:

- `presenter/host/mod.rs` 421→366:账户刷新/皮肤三段(refresh_accounts / attach_skins /
  request_skins)搬进 `host/accounts/refresh.rs`(`impl CoreHost`,pub(in crate::host))
- `presenter/state.rs` 410→392:`TileCell` + `grid_positions` 搬进 `state/tiles.rs`,
  `state.rs` 用 `pub use` 再导出,老路径不变
- `app-iced/app/state.rs` 401→394:账户页重建那一段搬进 `app/view/account_page.rs`
  的 `App::rebuild_account_page`
- `app-iced/app/update.rs` 407→388:`ULL_START_ROUTE` 取值表搬进 `app/update/boot_env.rs`
  (纯函数、无状态)
2026-11-05 05:13:54 +08:00

395 lines
21 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.
//! 应用状态:**只放字段与构造**,不放消息分支(那是 [`super::update`] 的事)。
//!
//! ## presenter 三件(`crates/presenter/src/lib.rs` crate 文档 §4)
//!
//! | 字段 | 谁给 | 干什么 |
//! |---|---|---|
//! | `backend: Box<dyn Backend>` | [`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::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 过期、
/// `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);
mod boot_probe;
use boot_probe::app_options;
pub(crate) use boot_probe::{log_first_frame, STARTED};
/// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。
pub struct App {
/// 启动参数(截图/主题/画面开关)。
pub(super) options: Options,
/// 字体族(切主题时重建 [`Ui`] 要用)。
pub(super) families: Families,
/// 令牌求值器(浅/深两套)。
pub(super) ui: Ui,
/// 后端通道(真 host 或 mock)。
pub(super) backend: Box<dyn Backend>,
/// 设置持久化桥。
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,
/// 界面语言(M106):用户档位 + 探测结论 + 目录选择。见 [`crate::ui::lang`]。
///
/// 为什么不在 `Ui` 里:`theme.rs` 是令牌求值器(颜色/字号),而语言是**运行期状态**——
/// 随设置变、要落盘、要重建视图模型(切主题只重建 Style)。
pub(super) lang: LangState,
/// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。
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<Instant>,
/// 窗口句柄(`window::latest()` 之后才有)。
pub(super) window: Option<window::Id>,
/// 已画的帧数(截图闸门用)。
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<isize>,
/// 设置页各组自己的运行期状态(M101 r2 的**状态漏斗**)。
///
/// 所有者在 `pages/settings/state.rs` 的 `Groups`:加/改某一组的字段只改那一组自己的文件,
/// 本字段与 `app/view.rs` 都不用动(`settings::States` 只看 `&Groups`,不看任何一组的字段)。
/// 放在字段列表**末尾**:后续 mission 若也要往这里加字段,冲突面最小。
pub(super) settings: pages::settings::Groups,
/// AI 助手栏的 UI 状态(M104):右栗三档 + 展开宽度 + 会话/草稿/未读。
///
/// 挂在 `App` 上而不是全局:这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,
/// 状态跟着 App 走才能 per-app、可测(tower 明确否掉了 `OnceLock<Mutex<..>>`)。
pub(super) agent: crate::shell::agent::AgentUi,
/// 下载页的安装草稿(M105:选中版本 + 加载器 + 加载器版本 + 实例名)。
///
/// 为什么住在 `App` 而不是页面模型:`refresh_view` 每次事件都把 `self.view` 整棵重建,
/// 页面自己存不住「用户正在填但还没确认」的东西;这与分类筛选(`view.download.category`)
/// 是同一个理由、同一个做法。
pub(super) download_draft: pages::InstallDraft,
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
pub(super) install_cancelled_seen: u64,
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
///
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
/// 而检索条件与结果(会跨页存活、要被事件写)在 `AppState::content`。放进 `view` 模型也
/// 不行——`refresh_view` 每次都用快照重建模型,选择会被冲回默认值。
pub(super) content: pages::content::PageState,
/// 账户页的**表单状态**(三条轨道的输入串;密码只活在这一次请求里)。
pub(super) account_form: pages::account::Form,
/// 账户页视图模型(与 `view` 同一条「状态变了才重建」的门控)。
pub(super) account: pages::account::Page,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache,
}
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<dyn Backend>,
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 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(),
};
// 配色来源档可能已经指向一张背景图:那时 `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
}
}