feat(app-iced): M90 —— app.rs 拆成 src/app/ + presenter 真接线 + 实例页端到端
拆分(行为不变,截图四件套逐字节相同):
- 400 行的 app.rs 拆成 src/app/{mod,state,message,update,subscription,view,boot,tests}.rs,
每个文件 ≤ 300 行;components 的 #[path] 跟着搬到 src/app/mod.rs(逻辑路径仍是 crate::app::components),
可见性放到 pub(crate) 让页面层能用真组件。
- 拆分前后各出 shell/gallery 浅深四张图,cmp 全部 IDENTICAL。
presenter 接线(crate 文档 §4):
- App 持有 Box<dyn Backend> + SettingsBridge + AppState;启动 build_backend + SettingsBridge::open + AppState::new。
- 截图通道强制 mock(D30 可复现);交互模式走真 host,--data-dir 从 M90 起真的被消费。
- Boot 抽干首屏快照(第 3 帧落盘,等节拍会把空页面拍进图);节拍 = 帧事件 + 33ms 时间闸门
(iced::time::every 要 tokio/smol,见 finding),tick → drain_events → AppState::apply。
- 用户动作不乐观回显:Launch/Stop 走 Backend::dispatch,状态等事件回来才变。
- 路由:pages::Message::Navigate(Route) 直接进 AppState::navigate,选中项 = 路由栈顶(不再有本地副本);
首屏自动选中第一行(list-detail 桌面惯例),只做一次。
实例页打通(其余三页保留占位视觉 + Message/update + 路由):
- 列表行换成 components::navigation::ListItem(真五态/选中),页头与详情动作换成 components::buttons
(Play 是唯一的实心 accent),分隔线换成 components::divider。
- 数据全部来自 presenter 快照:VERSION/LOADER/ISOLATION/VERSION ID/FILES/LAST PLAYED + 运行状态行。
- 导航轨换成真 NavigationRail(88),5 项各自压/替换路由。
版面约束未破(实测):列表列 336、正文列 424(元信息卡 206+12+206,右缘 896)、rail 88(药丸 16..71)、
断点 600/840 走 frame_metrics;新增 pages/tests.rs 的 9 条数值断言钉住它们。
This commit is contained in:
1 parent
270026338f
commit
b484998fc9
17 files changed
+1990
-676
No files matched your search
@@ -0,0 +1,189 @@
|
||||
//! 应用状态:**只放字段与构造**,不放消息分支(那是 [`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 快照 → 页面要画的东西,每拍重建一次
|
||||
//! (presenter 文档的原话:「`self.view = ViewModel::from(&self.state)`」)。缓存成字段是为了让
|
||||
//! `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, 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: std::time::Duration = std::time::Duration::from_millis(33);
|
||||
|
||||
/// 进程内「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` 的输入)。
|
||||
pub(super) ticks: u64,
|
||||
/// 上一次推进节拍的时刻(节拍由帧事件驱动,按真实流逝时间累加)。
|
||||
pub(super) last_tick: Instant,
|
||||
/// 窗口宽度(布局档位的判据;交互模式跟着 resize 事件走)。
|
||||
pub(super) width: f32,
|
||||
/// 展示转换缓存(presenter 快照 → 页面视图模型)。
|
||||
pub(super) view: pages::Model,
|
||||
/// 窗口句柄(`window::latest()` 之后才有)。
|
||||
pub(super) window: Option<window::Id>,
|
||||
/// 已画的帧数(截图闸门用)。
|
||||
pub(super) frames: u32,
|
||||
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
|
||||
pub(super) auto_selected: bool,
|
||||
}
|
||||
|
||||
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));
|
||||
let bridge = 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 view = pages::Model::new(
|
||||
pages::Screen::Instances,
|
||||
width,
|
||||
Some(pages::Screen::Instances.nav()),
|
||||
);
|
||||
|
||||
Self {
|
||||
ui: Ui::new(flavor, families),
|
||||
families,
|
||||
options,
|
||||
backend,
|
||||
bridge,
|
||||
state: AppState::new(),
|
||||
ticks: 0,
|
||||
last_tick: Instant::now(),
|
||||
width,
|
||||
view,
|
||||
window: None,
|
||||
frames: 0,
|
||||
auto_selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 本次进程是否走截图通道。
|
||||
pub(super) fn has_screenshot(&self) -> bool {
|
||||
self.options.screenshot.is_some()
|
||||
}
|
||||
|
||||
/// 切主题:只重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
|
||||
pub(super) fn set_flavor(&mut self, flavor: SchemeFlavor) {
|
||||
if self.ui.flavor() == flavor {
|
||||
return;
|
||||
}
|
||||
|
||||
let next = Ui::new(flavor, self.families);
|
||||
eprintln!(
|
||||
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
|
||||
self.ui.name(),
|
||||
next.name()
|
||||
);
|
||||
self.ui = next;
|
||||
}
|
||||
|
||||
/// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。
|
||||
pub(super) fn refresh_view(&mut self) {
|
||||
self.view = super::view::model(&self.state, self.width);
|
||||
}
|
||||
|
||||
/// 按设置文件里的主题模式切一次外观;`--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;
|
||||
}
|
||||
match self.bridge.load().1 {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user