P1-1 安装一旦收尾就再也装不了第二个版本(终态面板没有出口)
- 页面侧:`Download::from_snapshot` 把 **Done / Cancelled** 当结束(`InstallView::finished`),
面板让回列表 —— 装完一个版本可以直接选下一个继续装(图:r2-done / r2-done-next)。
不清 `Failed`:失败态带着用户要的东西(停在哪个文件 + 去日志页)。
- 面板加真出口:失败态「Open logs」(实心)+「Dismiss」(文本 → `Message::CancelInstall`,
host 侧对没在跑的任务是安全 no-op),修掉「失败后连重试都不可达」。
- 决策抽成纯函数 `install::panel_state()`(Hint / Ready / Install),`panel()` 只是它的画法,
于是「能不能再装」可以被单测钉死,不用开图形环境。
- 两条新测试**不走 MockBackend**(它从不发 `InstallProgress`,终态永远跑不到 ⇒ 假绿):
直接构造 `AppState` + `apply(CoreEvent::InstallProgress(..))`。
P2-1 分类 chip 在真 host 上是空控件
- `from_snapshot(state, category, catalog)` 收分类并按 `Category::matches` **本地过滤**
(真 host 的 `ListVersions(_)` 丢掉分类参数、回全量清单);
- 分类不再从列表反推(`Category::of` 只留给演示首帧),`app/view.rs::model` 收分类、
`refresh_view` 把上一份视图模型的分类带回去 ⇒ 事件回来 chip 不会跳回「全部」;
- `update.rs` 的 `SetCategory` 写分类后**立刻重建**(chip 与列表同一个谓词),再派真指令。
p3(顺带)
- 失败态与占位态共存:列表空/加载中时若安装还在跑,面板照样出现在底部(r2-installing-empty);
- 修 `install.rs` 的坏 rustdoc 链接;`install.rs` / `model.rs` 的测试模块外置到子文件守住 400 行。
测试文件:`pages/download/{model,install}/tests.rs`(新),`pages/download/tests.rs` 里的
`from_snapshot` 调用补分类参数。
实证(回退法):删掉 `.filter(|view| !view.finished())` → 两条终态测试红并点名
「Done 之后不许再占着安装面板」;删掉 `.filter(|category.matches(..))` → 两条分类测试红
(「只剩正式版」left=3 right=1)。放回修复 → 全绿。
门禁:fmt --check 干净、clippy -D warnings 干净、app-iced 280 + 集成 6 + i18n 23 全绿(-j 4)。
317 lines
15 KiB
Rust
317 lines
15 KiB
Rust
//! 应用状态:**只放字段与构造**,不放消息分支(那是 [`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::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<Instant> = 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<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,
|
||
/// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。
|
||
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>,
|
||
}
|
||
|
||
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 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 <tmp>` 不会往那个目录写东西。
|
||
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
|
||
);
|
||
}
|
||
}
|