diff --git a/Cargo.lock b/Cargo.lock index 61545ba..1e3da14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6894,6 +6894,7 @@ dependencies = [ "unlucky-agent", "unlucky-cli", "unlucky-core", + "unlucky-presenter", ] [[package]] @@ -6904,6 +6905,7 @@ dependencies = [ "image", "unlucky-cli", "unlucky-design", + "unlucky-presenter", ] [[package]] @@ -6940,6 +6942,15 @@ dependencies = [ name = "unlucky-design" version = "0.1.0" +[[package]] +name = "unlucky-presenter" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "unlucky-core", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 7562c83..1caed83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,9 @@ resolver = "2" members = [ "crates/core", "crates/agent", + # M85(D26):UI 框架无关的宿主层。Slint 版(crates/app)与 iced 版(crates/app-iced) + # 共用它;Slint 退役后只剩 iced 依赖,本 crate 不受影响。 + "crates/presenter", "crates/app", "crates/cli", "crates/design", diff --git a/crates/app-iced/Cargo.toml b/crates/app-iced/Cargo.toml index 8e60213..5df31dc 100644 --- a/crates/app-iced/Cargo.toml +++ b/crates/app-iced/Cargo.toml @@ -24,6 +24,11 @@ path = "src/lib.rs" # 令牌唯一真源(M61):颜色/排版/形状/间距/图标几何全部只从这里取。 unlucky-design = { path = "../design", version = "0.1" } +# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host)。**只登记依赖,本 mission 不接线**—— +# 页面用它的时机由后续 iced 页面 mission 决定(接线流程见 crates/presenter/src/lib.rs 的 crate 文档)。 +# 与 Slint 版共用同一套 host 与状态语义,这是 D26「重写只换渲染」的落点。 +unlucky-presenter = { path = "../presenter", version = "0.1" } + # UI 框架(D26 锁定 iced 0.14)。**不得依赖 slint**。 # 关掉默认 features 后必须显式给执行器(M60 踩坑 Q2:否则 `No futures executor has been enabled`), # 这里给 `thread-pool`(iced 自己的默认执行器,不额外引 tokio 运行时)。 diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 1fb2cde..a83e5ff 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -21,9 +21,18 @@ serde.workspace = true # host 层要读 core 设置/事件里的 JSON 值(serde_json 是 workspace 既有依赖) serde_json.workspace = true +# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host / 设置桥 / 偏好 / 路由 / 对话框 / 提示 / +# 向导 / 假后端)。Slint 版与 iced 版共用这一层,所以 `crates/app` 只是它的**消费者**: +# 本 crate 里剩下的 `crate::` 引用都换成了 `unlucky_presenter::`。 +# +# presenter 的零 UI 依赖守门(原文与实测输出就在这里,放不进 present 的自家文档——自指): +# grep -rn slint crates/presenter # 期望:无输出 +# cargo tree -p unlucky-presenter --edges normal # 期望:只有 serde/serde_json/unlucky-core +unlucky-presenter = { path = "../presenter", version = "0.1" } + # 边界(01c §5 / D3):app 只经 DTO 消费 core/agent,**不 import 内部类型**。 -# P0 的 host 是 src/mock.rs 的假后端;这两个依赖在这里登记,是为了后续把真实 host -# (core 的快照/事件流翻译成 src/dto 的类型)落位时不必再动 workspace 契约。 +# 迁移到 presenter 之后,app 侧只剩视图模型转换(`conv/ui.rs` 的 BgFill/强调色映射)与 +# 系统主题探测需要 core;业务装配(读盘/起进程/写设置)全部在 presenter 的 host 里。 unlucky-core = { path = "../core", version = "0.1" } unlucky-agent = { path = "../agent", version = "0.1" } # M82:同一个二进制也带 CLI 子命令(`unlucky-launcher instances list` …)。 diff --git a/crates/app/src/bg_image.rs b/crates/app/src/bg_image.rs index 0dca80e..a47004e 100644 --- a/crates/app/src/bg_image.rs +++ b/crates/app/src/bg_image.rs @@ -13,7 +13,7 @@ use slint::{Image, Rgba8Pixel, SharedPixelBuffer}; -use crate::prefs::BgFill; +use unlucky_presenter::prefs::BgFill; /// 模糊层目标宽度(像素)。越小越糊;96 在 1180px 宽窗口上 ≈ 24px 半径高斯的效果。 const BLUR_WIDTH: u32 = 96; diff --git a/crates/app/src/callbacks.rs b/crates/app/src/callbacks.rs index d3ddcdd..eab031a 100644 --- a/crates/app/src/callbacks.rs +++ b/crates/app/src/callbacks.rs @@ -8,10 +8,10 @@ use std::rc::Weak; use slint::ComponentHandle; use crate::conv; -use crate::dto::AppCommand; -use crate::router::Route; -use crate::toast::ToastKind; use crate::ui_bind::Inner; +use unlucky_presenter::dto::AppCommand; +use unlucky_presenter::router::Route; +use unlucky_presenter::toast::ToastKind; /// 接全部回调;闭包持弱引用,避免 `MainWindow` 与闭包互相持有导致窗口永不释放。 pub(crate) fn wire(weak: Weak) { @@ -181,7 +181,7 @@ fn wire_instances(ui: &crate::MainWindow, weak: &Weak) { ui.on_new_instance(move || { let Some(this) = weak.upgrade() else { return }; // G5:打开向导(版本 → 加载器 → 名字 → 确认 → 安装进度) - this.state.borrow_mut().wizard = crate::wizard::WizardState::open(); + this.state.borrow_mut().wizard = unlucky_presenter::wizard::WizardState::open(); this.sync(); }); } diff --git a/crates/app/src/callbacks_pages.rs b/crates/app/src/callbacks_pages.rs index cdb89da..19623f9 100644 --- a/crates/app/src/callbacks_pages.rs +++ b/crates/app/src/callbacks_pages.rs @@ -8,8 +8,8 @@ use std::rc::Weak; use slint::ComponentHandle; use crate::conv; -use crate::dto::{AppCommand, CreateRequest, LoaderKind}; use crate::ui_bind::Inner; +use unlucky_presenter::dto::{AppCommand, CreateRequest, LoaderKind}; /// 接全部页面状态回调。 pub(crate) fn wire_pages(weak: Weak) { @@ -137,7 +137,7 @@ pub(crate) fn wire_pages(weak: Weak) { let weak = weak.clone(); page.on_wizard_open(move || { let Some(this) = weak.upgrade() else { return }; - this.state.borrow_mut().wizard = crate::wizard::WizardState::open(); + this.state.borrow_mut().wizard = unlucky_presenter::wizard::WizardState::open(); this.sync(); }); } diff --git a/crates/app/src/conv/agent.rs b/crates/app/src/conv/agent.rs index bb1ed4a..9e32255 100644 --- a/crates/app/src/conv/agent.rs +++ b/crates/app/src/conv/agent.rs @@ -1,10 +1,10 @@ //! AI 页与覆盖层:会话消息、对话框(一次一条)、Toast。 -use crate::dialogs as dlg; -use crate::dto::ChatRole as DtoChatRole; -use crate::state::AppState; -use crate::toast as toast_mod; use crate::{ChatRole, DialogKind, UiChatMessage, UiDialog, UiToast}; +use unlucky_presenter::dialogs as dlg; +use unlucky_presenter::dto::ChatRole as DtoChatRole; +use unlucky_presenter::state::AppState; +use unlucky_presenter::toast as toast_mod; /// AI 会话消息。 pub fn chat(state: &AppState) -> Vec { diff --git a/crates/app/src/conv/instances.rs b/crates/app/src/conv/instances.rs index 11ce32a..ddf60fb 100644 --- a/crates/app/src/conv/instances.rs +++ b/crates/app/src/conv/instances.rs @@ -2,9 +2,9 @@ use slint::Color; -use crate::dto::{self, InstanceRuntime}; -use crate::state::{AppState, TileCell}; use crate::{LoaderKind, RuntimePhase, UiTile}; +use unlucky_presenter::dto::{self, InstanceRuntime}; +use unlucky_presenter::state::{AppState, TileCell}; /// 头像/图标占位的底色池(Metro 强调色系,按 id 哈希稳定取色)。 const HUES: [u32; 8] = [ diff --git a/crates/app/src/conv/logs.rs b/crates/app/src/conv/logs.rs index ba72410..b678621 100644 --- a/crates/app/src/conv/logs.rs +++ b/crates/app/src/conv/logs.rs @@ -1,8 +1,8 @@ //! 日志页:会话列表、日志行、D8 诊断卡片。 -use crate::dto::{LogLevel as DtoLogLevel, LogOutcome as DtoLogOutcome}; -use crate::state::AppState; use crate::{LogLevel, LogOutcome, UiDiagnosis, UiLogLine, UiLogSession}; +use unlucky_presenter::dto::{LogLevel as DtoLogLevel, LogOutcome as DtoLogOutcome}; +use unlucky_presenter::state::AppState; /// 日志会话列表。 pub fn log_sessions(state: &AppState) -> Vec { diff --git a/crates/app/src/conv/pages.rs b/crates/app/src/conv/pages.rs index 4c1a4df..b375c1e 100644 --- a/crates/app/src/conv/pages.rs +++ b/crates/app/src/conv/pages.rs @@ -5,17 +5,17 @@ use slint::{ModelRc, SharedString, VecModel}; -use crate::dto::{ - AccountKind as DtoAccountKind, DeviceLoginPhase, InstallStage as DtoInstallStage, - JavaChainOutcome, NoticeLevel, SettingControl, VersionKind as DtoVersionKind, -}; -use crate::state::AppState; -use crate::wizard::WizardStep; use crate::{ AccountKind, InstallStage, JavaStepOutcome, NoticeKind, SettingControl as UiControl, UiAccount, UiDeviceCode, UiInstallProgress, UiJavaRuntime, UiJavaStep, UiJavaVendor, UiNotice, UiSettingGroup, UiSettingRow, UiVersion, UiWizard, }; +use unlucky_presenter::dto::{ + AccountKind as DtoAccountKind, DeviceLoginPhase, InstallStage as DtoInstallStage, + JavaChainOutcome, NoticeLevel, SettingControl, VersionKind as DtoVersionKind, +}; +use unlucky_presenter::state::AppState; +use unlucky_presenter::wizard::WizardStep; fn strings(items: &[String]) -> ModelRc { ModelRc::new(VecModel::from( @@ -162,8 +162,8 @@ pub fn install(state: &AppState) -> UiInstallProgress { bytes_label: if progress.bytes_total > 0 { format!( "{}/{}", - crate::dto::human_bytes(progress.bytes_done), - crate::dto::human_bytes(progress.bytes_total) + unlucky_presenter::dto::human_bytes(progress.bytes_done), + unlucky_presenter::dto::human_bytes(progress.bytes_total) ) } else { String::new() @@ -236,7 +236,7 @@ pub fn wizard(state: &AppState) -> UiWizard { let step = wizard.step; let can_next = match step { WizardStep::Version => !wizard.version.trim().is_empty(), - WizardStep::Name => crate::wizard::validate_name(&wizard.name).is_ok(), + WizardStep::Name => unlucky_presenter::wizard::validate_name(&wizard.name).is_ok(), WizardStep::Loader | WizardStep::Confirm => true, WizardStep::Progress => false, }; @@ -248,7 +248,7 @@ pub fn wizard(state: &AppState) -> UiWizard { step_total: 5, version: wizard.version.as_str().into(), loader: loader_index(wizard.loader), - loader_label: if wizard.loader == crate::dto::LoaderKind::Vanilla { + loader_label: if wizard.loader == unlucky_presenter::dto::LoaderKind::Vanilla { "原版".to_string() } else { format!("{} {}", wizard.loader.label(), wizard.loader_version.trim()) @@ -265,14 +265,15 @@ pub fn wizard(state: &AppState) -> UiWizard { } /// 加载器在向导里的单选序号(与 `wizard.slint` 的 LoaderChoice 顺序一致)。 -pub fn loader_index(loader: crate::dto::LoaderKind) -> i32 { +pub fn loader_index(loader: unlucky_presenter::dto::LoaderKind) -> i32 { match loader { - crate::dto::LoaderKind::Vanilla => 0, - crate::dto::LoaderKind::Fabric => 1, - crate::dto::LoaderKind::Quilt => 2, - crate::dto::LoaderKind::Forge => 3, - crate::dto::LoaderKind::NeoForge => 4, - crate::dto::LoaderKind::OptiFine | crate::dto::LoaderKind::LiteLoader => 0, + unlucky_presenter::dto::LoaderKind::Vanilla => 0, + unlucky_presenter::dto::LoaderKind::Fabric => 1, + unlucky_presenter::dto::LoaderKind::Quilt => 2, + unlucky_presenter::dto::LoaderKind::Forge => 3, + unlucky_presenter::dto::LoaderKind::NeoForge => 4, + unlucky_presenter::dto::LoaderKind::OptiFine + | unlucky_presenter::dto::LoaderKind::LiteLoader => 0, } } diff --git a/crates/app/src/conv/pages_tests.rs b/crates/app/src/conv/pages_tests.rs index cd7f23b..e6e6d09 100644 --- a/crates/app/src/conv/pages_tests.rs +++ b/crates/app/src/conv/pages_tests.rs @@ -1,12 +1,12 @@ //! conv/pages.rs 的单测(拆文件守 400 行上限)。 use super::*; -use crate::dto::{ +use slint::Model; +use unlucky_presenter::dto::{ AccountView, DeviceLoginView, InstallProgressView, JavaPageView, JavaVendorView, NoticeView, SettingGroupView, SettingRowView, VersionSummary, }; -use crate::wizard::WizardState; -use slint::Model; +use unlucky_presenter::wizard::WizardState; #[test] fn accounts_and_device_login_render() { @@ -90,7 +90,7 @@ fn java_chain_and_vendors_map_outcomes() { sources: vec!["BMCLAPI".to_string(), "GitHub".to_string()], installed: true, }], - chain: vec![crate::dto::JavaChainStep { + chain: vec![unlucky_presenter::dto::JavaChainStep { vendor: "temurin".to_string(), outcome: JavaChainOutcome::Hit, detail: "21".to_string(), @@ -161,7 +161,7 @@ fn wizard_versions_skip_ancient_ones() { }, VersionSummary { id: "rd-132211".to_string(), - kind: DtoVersionKind::Ancient(crate::dto::AncientKind::Classic), + kind: DtoVersionKind::Ancient(unlucky_presenter::dto::AncientKind::Classic), released: "2009-05-13".to_string(), installed: false, }, diff --git a/crates/app/src/conv/ui.rs b/crates/app/src/conv/ui.rs index 22c6d20..d9fb094 100644 --- a/crates/app/src/conv/ui.rs +++ b/crates/app/src/conv/ui.rs @@ -1,9 +1,9 @@ //! UI 偏好/路由枚举的双向映射(Slint 枚举 ↔ 状态层枚举)。 -use crate::dto::{self, VersionCategory}; -use crate::prefs; -use crate::router; use crate::{BgFillKind, DownloadCategory, InstanceView, RouteKind, ThemeMode, TileSize}; +use unlucky_presenter::dto::{self, VersionCategory}; +use unlucky_presenter::prefs; +use unlucky_presenter::router; /// 路由 → 导航高亮/页面选择。 pub fn route(route: &router::Route) -> RouteKind { diff --git a/crates/app/src/conv/versions.rs b/crates/app/src/conv/versions.rs index 3459dd4..7105742 100644 --- a/crates/app/src/conv/versions.rs +++ b/crates/app/src/conv/versions.rs @@ -1,8 +1,10 @@ //! 版本列表与下载分类映射(下载页)。 -use crate::dto::{AncientKind, VersionCategory, VersionKind as DtoVersionKind, VersionSummary}; -use crate::state::AppState; use crate::{UiVersion, VersionKind}; +use unlucky_presenter::dto::{ + AncientKind, VersionCategory, VersionKind as DtoVersionKind, VersionSummary, +}; +use unlucky_presenter::state::AppState; /// 分类侧栏 → 该分类下的版本(远古版按子类细分)。 pub fn in_category(category: VersionCategory, kind: DtoVersionKind) -> bool { @@ -58,8 +60,8 @@ fn version_kind(kind: DtoVersionKind) -> VersionKind { #[cfg(test)] mod tests { use super::versions; - use crate::dto::{VersionKind, VersionSummary}; - use crate::state::AppState; + use unlucky_presenter::dto::{VersionKind, VersionSummary}; + use unlucky_presenter::state::AppState; fn state_with_versions() -> AppState { let mut state = AppState::new(); diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 03789a8..13e23d9 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -2,7 +2,7 @@ //! //! 边界(决策 D3 / D16):本 crate 是**唯一**允许依赖 UI 框架的 crate; //! 它只经「快照 + 事件流 + 注入的显示服务」消费 core/agent,不直接触碰其内部类型 -//! (P0 的 host 是 `mock` 假后端,`crate::dto` 是唯一跨界类型集合)。 +//! (P0 的 host 是 `mock` 假后端,`unlucky_presenter::dto` 是唯一跨界类型集合)。 //! //! 渲染策略(`DevDocs/PlanDoc/01c-native-gui-options.md` §6): //! GL 优先(femtovg),启动参数 `--software-renderer` 或 Win7 老机探测失败时切 CPU 软渲染。 @@ -10,7 +10,7 @@ //! 主题与外观(D17 / 09 §101-113): //! - 主题三态 `appearance.theme_mode ∈ {auto, light, dark}`(出厂 auto); //! - 「外观(高级)」四件(背景图 / 面板半透明度 / 玻璃拟态 / 强调色)出厂全关; -//! - 全部走 `core::setting` 的 schema(`crate::settings_bridge`),AI 可写并且**热切** +//! - 全部走 `core::setting` 的 schema(`unlucky_presenter::settings_bridge`),AI 可写并且**热切** //! (tick 里 1s 一次 mtime 比对);系统主题由 `core::platform::theme_probe` 对齐 + 兜底轮询。 //! //! 一个二进制两种用法(M82):`unlucky-launcher` 不带子命令时起 GUI;带子命令 @@ -41,27 +41,15 @@ use std::process::ExitCode; mod appearance_bind; -mod backend; mod bg_image; mod callbacks; mod callbacks_pages; mod conv; -mod dialogs; -mod dto; -mod host; -mod mock; -mod mock_data; mod page_sync; mod png; -mod prefs; -mod router; mod screenshot; -mod settings_bridge; -mod state; mod theme_runtime; -mod toast; mod ui_bind; -mod wizard; slint::include_modules!(); diff --git a/crates/app/src/page_sync.rs b/crates/app/src/page_sync.rs index 3ed06ea..578e0c8 100644 --- a/crates/app/src/page_sync.rs +++ b/crates/app/src/page_sync.rs @@ -4,10 +4,10 @@ //! (`ui_bind::Inner::sync`)。分文件而不是砍内容:这些字段每一行都对应一个页面元素。 use crate::conv; -use crate::state::AppState; use crate::ui_bind::model; use crate::{MainWindow, PageState}; use slint::ComponentHandle; +use unlucky_presenter::state::AppState; /// 把页面状态灌进 `PageState`。 pub(crate) fn apply(ui: &MainWindow, state: &AppState) { diff --git a/crates/app/src/screenshot.rs b/crates/app/src/screenshot.rs index 50d74a4..7c761f0 100644 --- a/crates/app/src/screenshot.rs +++ b/crates/app/src/screenshot.rs @@ -9,9 +9,9 @@ use std::path::PathBuf; -use crate::backend::AppOptions; -use crate::dto::AppCommand; use crate::ui_bind::AppUi; +use unlucky_presenter::backend::AppOptions; +use unlucky_presenter::dto::AppCommand; /// 一次截图请求。 #[derive(Debug, Clone, Default)] @@ -121,8 +121,8 @@ pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::Platform /// 截图通道的安装指令:缺省原版,给了 `--screenshot-loader` 就走向导同一条路 /// (`CreateInstance`),因此 Forge/NeoForge 的中间态也能取图。 fn install_command(version: &str, loader: Option<&str>) -> AppCommand { - match loader.map(crate::dto::LoaderKind::parse) { - Some(Some(kind)) => AppCommand::CreateInstance(crate::dto::CreateRequest { + match loader.map(unlucky_presenter::dto::LoaderKind::parse) { + Some(Some(kind)) => AppCommand::CreateInstance(unlucky_presenter::dto::CreateRequest { version: version.to_string(), loader: kind, loader_version: String::new(), diff --git a/crates/app/src/theme_runtime.rs b/crates/app/src/theme_runtime.rs index 851e612..b8fccab 100644 --- a/crates/app/src/theme_runtime.rs +++ b/crates/app/src/theme_runtime.rs @@ -7,15 +7,16 @@ use slint::ComponentHandle; use crate::bg_image::{self, BackgroundLayers}; use crate::conv; -use crate::prefs::{AppearancePrefs, BgFill, ThemeMode}; -use crate::toast::ToastKind; -use crate::ui_bind::{ - BackgroundKey, Inner, CONFIG_POLL_TICKS, SYSTEM_THEME_POLL_TICKS, TILE_REGENERATE_STEP, +use crate::ui_bind::Inner; +use unlucky_presenter::prefs::{AppearancePrefs, BgFill, ThemeMode}; +use unlucky_presenter::theme::{ + config_poll_due, system_theme_poll_due, system_theme_value, BackgroundKey, }; +use unlucky_presenter::toast::ToastKind; impl Inner { pub(crate) fn poll_settings(&self) { - if !self.ticks.get().is_multiple_of(CONFIG_POLL_TICKS) { + if !config_poll_due(self.ticks.get()) { return; } let Some((appearance, theme)) = self.bridge.borrow_mut().reload_if_changed() else { @@ -35,17 +36,17 @@ impl Inner { pub(crate) fn poll_system_theme(&self) { let auto = matches!(self.state.borrow().prefs.theme, ThemeMode::Auto); let backend_known = self.ui.get_system_theme_known(); - let due = !self.system_theme_probed.get() - || (auto && !backend_known && self.ticks.get().is_multiple_of(SYSTEM_THEME_POLL_TICKS)); + let due = system_theme_poll_due( + self.system_theme_probed.get(), + auto, + backend_known, + self.ticks.get(), + ); if !due { return; } self.system_theme_probed.set(true); - let value = match unlucky_core::platform::theme_probe::probe() { - unlucky_core::platform::theme_probe::SystemTheme::Light => 1, - unlucky_core::platform::theme_probe::SystemTheme::Dark => 2, - unlucky_core::platform::theme_probe::SystemTheme::Unknown => 0, - }; + let value = system_theme_value(); if self.ui.get_system_theme() != value { self.ui.set_system_theme(value); } @@ -124,16 +125,15 @@ impl Inner { } } - /// tile 画布的窗口尺寸(量化到 [`TILE_REGENERATE_STEP`],避免拖动窗口时反复重算)。 + /// tile 画布的窗口尺寸(量化到 `TILE_REGENERATE_STEP`,避免拖动窗口时反复重算)。 + /// + /// 判据在 [`unlucky_presenter::theme::tile_viewport`](两侧 UI 共用一份);这里只负责 + /// 提供窗口物理尺寸。 fn tile_viewport(&self, fill: BgFill) -> (u32, u32) { if fill != BgFill::Tile { return (0, 0); } - let (width, height) = self.window_physical_size(); - ( - width / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP, - height / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP, - ) + unlucky_presenter::theme::tile_viewport(fill, self.window_physical_size()) } /// 窗口物理像素尺寸(取不到时 (0, 0),tile 退化为单块)。 diff --git a/crates/app/src/ui_bind.rs b/crates/app/src/ui_bind.rs index b978ec2..6b5d07e 100644 --- a/crates/app/src/ui_bind.rs +++ b/crates/app/src/ui_bind.rs @@ -14,27 +14,18 @@ use std::time::Duration; use slint::{ComponentHandle, ModelRc, VecModel}; -use crate::backend::{build_backend, config_path, AppOptions}; use crate::bg_image::BackgroundLayers; use crate::conv; -use crate::dto::{AppCommand, Backend}; -use crate::prefs::BgFill; -use crate::settings_bridge::SettingsBridge; -use crate::state::AppState; use crate::{MainWindow, PageState, SettingSection}; +use unlucky_presenter::backend::{build_backend, config_path, AppOptions}; +use unlucky_presenter::dto::{AppCommand, Backend}; +use unlucky_presenter::settings_bridge::SettingsBridge; +use unlucky_presenter::state::AppState; +use unlucky_presenter::theme::BackgroundKey; /// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。 const TICK_MS: u64 = 33; -/// 配置热切轮询间隔(拍数):约 1s 一次 `mtime` 比对,AI/CLI 改完下一拍即生效。 -pub(crate) const CONFIG_POLL_TICKS: u64 = 30; - -/// 系统主题兜底轮询间隔(拍数):只在 `auto` 且后端不认识系统主题时启动(约 3s)。 -pub(crate) const SYSTEM_THEME_POLL_TICKS: u64 = 90; - -/// tile 模式重建画布的窗口尺寸粒度(像素):避免拖动窗口时反复重算平铺画布。 -pub(crate) const TILE_REGENERATE_STEP: u32 = 128; - /// 应用外壳:持有窗口、状态与后端,把三者缝在一起。 pub struct AppUi { inner: Rc, @@ -57,15 +48,6 @@ pub(crate) struct Inner { pub(crate) system_theme_probed: Cell, } -/// 背景图加工输入(值相同就不重新解码)。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct BackgroundKey { - pub(crate) path: String, - pub(crate) fill: BgFill, - /// tile 用的窗口尺寸(按 [`TILE_REGENERATE_STEP`] 量化;cover/contain 恒为 (0, 0)) - pub(crate) viewport: (u32, u32), -} - impl AppUi { /// 建状态 + 真实后端、读配置、接全部回调、灌初始事件并做第一次同步。 pub fn new(ui: MainWindow) -> Self { @@ -136,7 +118,7 @@ impl AppUi { self.inner .state .borrow_mut() - .navigate(crate::router::Route::Settings); + .navigate(unlucky_presenter::router::Route::Settings); self.inner.sync(); self.inner .ui @@ -147,11 +129,11 @@ impl AppUi { /// 直接跳到一个页面(截图验收用:每页一张)。 pub fn open_page(&self, page: &str) { let route = match page { - "download" | "java" => crate::router::Route::Download, - "logs" => crate::router::Route::Logs, - "settings" | "account" | "appearance" => crate::router::Route::Settings, - "agent" => crate::router::Route::Agent, - _ => crate::router::Route::Instances, + "download" | "java" => unlucky_presenter::router::Route::Download, + "logs" => unlucky_presenter::router::Route::Logs, + "settings" | "account" | "appearance" => unlucky_presenter::router::Route::Settings, + "agent" => unlucky_presenter::router::Route::Agent, + _ => unlucky_presenter::router::Route::Instances, }; { let mut state = self.inner.state.borrow_mut(); @@ -184,7 +166,7 @@ impl AppUi { /// 打开新建实例向导(截图验收用)。 pub fn open_wizard(&self) { - self.inner.state.borrow_mut().wizard = crate::wizard::WizardState::open(); + self.inner.state.borrow_mut().wizard = unlucky_presenter::wizard::WizardState::open(); self.inner.sync(); slint::platform::update_timers_and_animations(); } diff --git a/crates/presenter/Cargo.toml b/crates/presenter/Cargo.toml new file mode 100644 index 0000000..1bd0061 --- /dev/null +++ b/crates/presenter/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "unlucky-presenter" +description = "Unlucky-Launcher 的 UI 框架无关宿主层:DTO / 状态 reducer / host 业务装配,Slint 版与 iced 版共用" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +# 零 UI 框架依赖是硬约束(M85 / D26):本 crate 是 Slint 版(`crates/app`)与 iced 版 +# (`crates/app-iced`)共用的宿主层,任何时候都不得出现 Slint / iced / winit / gtk / egui 等 +# UI 或渲染框架,也不得出现「把窗口属性灌进去」这种绑定代码。 +# 证据命令写在 `crates/app/Cargo.toml` 的注释里(那个文件允许出现框架名,本 crate 的守门 +# 命令本身在自家 grep 范围内会把自己抓出来,所以那两条命令刻意不放在这里)。 +[dependencies] +# 跨边界 DTO 必须可序列化(AGENTS §6):事件与快照是 IPC 契约,序列化能力由 derive 钉住。 +serde.workspace = true +# host 层要读 core 设置/事件里的 JSON 值。 +serde_json.workspace = true + +# 边界(01c §5 / D3):presenter 只经 DTO 消费 core,**不 import core 的领域类型到 DTO 里**。 +# host 是 core 的快照/事件流与本 crate DTO 之间的唯一翻译层,所以它需要 core。 +unlucky-core = { path = "../core", version = "0.1" } + +[lints] +workspace = true diff --git a/crates/app/src/backend.rs b/crates/presenter/src/backend.rs similarity index 90% rename from crates/app/src/backend.rs rename to crates/presenter/src/backend.rs index 224b786..1669aec 100644 --- a/crates/app/src/backend.rs +++ b/crates/presenter/src/backend.rs @@ -17,12 +17,12 @@ pub struct AppOptions { } /// `/.ULL/config/launcher.json`(与 `SettingsBridge::open` 同落点)。 -pub(crate) fn config_path(root: &Path) -> PathBuf { +pub fn config_path(root: &Path) -> PathBuf { root.join(".ULL").join("config").join("launcher.json") } /// 选后端:真实 host 优先;数据根解析不出来或显式 `--mock` 时退回假后端。 -pub(crate) fn build_backend(options: &AppOptions) -> Box { +pub fn build_backend(options: &AppOptions) -> Box { if options.mock { return Box::new(MockBackend::new()); } diff --git a/crates/app/src/dialogs.rs b/crates/presenter/src/dialogs.rs similarity index 100% rename from crates/app/src/dialogs.rs rename to crates/presenter/src/dialogs.rs diff --git a/crates/app/src/dto/accounts.rs b/crates/presenter/src/dto/accounts.rs similarity index 100% rename from crates/app/src/dto/accounts.rs rename to crates/presenter/src/dto/accounts.rs diff --git a/crates/app/src/dto/agent.rs b/crates/presenter/src/dto/agent.rs similarity index 100% rename from crates/app/src/dto/agent.rs rename to crates/presenter/src/dto/agent.rs diff --git a/crates/app/src/dto/events.rs b/crates/presenter/src/dto/events.rs similarity index 100% rename from crates/app/src/dto/events.rs rename to crates/presenter/src/dto/events.rs diff --git a/crates/app/src/dto/install.rs b/crates/presenter/src/dto/install.rs similarity index 100% rename from crates/app/src/dto/install.rs rename to crates/presenter/src/dto/install.rs diff --git a/crates/app/src/dto/instances.rs b/crates/presenter/src/dto/instances.rs similarity index 100% rename from crates/app/src/dto/instances.rs rename to crates/presenter/src/dto/instances.rs diff --git a/crates/app/src/dto/java.rs b/crates/presenter/src/dto/java.rs similarity index 100% rename from crates/app/src/dto/java.rs rename to crates/presenter/src/dto/java.rs diff --git a/crates/app/src/dto/logs.rs b/crates/presenter/src/dto/logs.rs similarity index 100% rename from crates/app/src/dto/logs.rs rename to crates/presenter/src/dto/logs.rs diff --git a/crates/app/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs similarity index 84% rename from crates/app/src/dto/mod.rs rename to crates/presenter/src/dto/mod.rs index 38c2cf0..4bd5e6a 100644 --- a/crates/app/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -1,6 +1,6 @@ -//! app ↔ core/agent 的 IPC 边界(01c §5 纪律)。 +//! presenter ↔ core/agent 的 IPC 边界(01c §5 纪律)。 //! -//! 这里只允许出现「扁平 DTO / 事件 / 指令」:`crates/app` 任何模块都不得 `use unlucky_core::*` +//! 这里只允许出现「扁平 DTO / 事件 / 指令」:本 crate 的 DTO 里不得出现 `unlucky_core::*` //! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型, //! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架, //! P3 起由 [`crate::host`] 的真实后端驱动全部页面。 @@ -8,7 +8,8 @@ //! 结构:**按领域拆子模块**(instances / versions / logs / agent / accounts / java / //! settings / install / notices / events),全量 `pub use` 出去,调用方依旧写 //! `crate::dto::InstanceSummary` 这类路径。 -//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`。 +//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`; +//! UI 侧的唯一入口与 iced 接线用法见 crate 文档(`src/lib.rs`)。 //! //! 全部跨界类型 `Serialize + Deserialize`(AGENTS §6):事件与快照是 IPC 契约, //! 序列化能力由 derive 钉住,`dto` 的单测做 round-trip 断言。 diff --git a/crates/app/src/dto/notices.rs b/crates/presenter/src/dto/notices.rs similarity index 100% rename from crates/app/src/dto/notices.rs rename to crates/presenter/src/dto/notices.rs diff --git a/crates/app/src/dto/settings.rs b/crates/presenter/src/dto/settings.rs similarity index 100% rename from crates/app/src/dto/settings.rs rename to crates/presenter/src/dto/settings.rs diff --git a/crates/app/src/dto/versions.rs b/crates/presenter/src/dto/versions.rs similarity index 100% rename from crates/app/src/dto/versions.rs rename to crates/presenter/src/dto/versions.rs diff --git a/crates/app/src/host/accounts.rs b/crates/presenter/src/host/accounts.rs similarity index 100% rename from crates/app/src/host/accounts.rs rename to crates/presenter/src/host/accounts.rs diff --git a/crates/app/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs similarity index 100% rename from crates/app/src/host/dispatch.rs rename to crates/presenter/src/host/dispatch.rs diff --git a/crates/app/src/host/engine.rs b/crates/presenter/src/host/engine.rs similarity index 100% rename from crates/app/src/host/engine.rs rename to crates/presenter/src/host/engine.rs diff --git a/crates/app/src/host/env.rs b/crates/presenter/src/host/env.rs similarity index 100% rename from crates/app/src/host/env.rs rename to crates/presenter/src/host/env.rs diff --git a/crates/app/src/host/game.rs b/crates/presenter/src/host/game.rs similarity index 100% rename from crates/app/src/host/game.rs rename to crates/presenter/src/host/game.rs diff --git a/crates/app/src/host/handle.rs b/crates/presenter/src/host/handle.rs similarity index 100% rename from crates/app/src/host/handle.rs rename to crates/presenter/src/host/handle.rs diff --git a/crates/app/src/host/identity.rs b/crates/presenter/src/host/identity.rs similarity index 100% rename from crates/app/src/host/identity.rs rename to crates/presenter/src/host/identity.rs diff --git a/crates/app/src/host/install.rs b/crates/presenter/src/host/install.rs similarity index 100% rename from crates/app/src/host/install.rs rename to crates/presenter/src/host/install.rs diff --git a/crates/app/src/host/install_journal.rs b/crates/presenter/src/host/install_journal.rs similarity index 100% rename from crates/app/src/host/install_journal.rs rename to crates/presenter/src/host/install_journal.rs diff --git a/crates/app/src/host/install_processors.rs b/crates/presenter/src/host/install_processors.rs similarity index 100% rename from crates/app/src/host/install_processors.rs rename to crates/presenter/src/host/install_processors.rs diff --git a/crates/app/src/host/install_processors_fixture.rs b/crates/presenter/src/host/install_processors_fixture.rs similarity index 100% rename from crates/app/src/host/install_processors_fixture.rs rename to crates/presenter/src/host/install_processors_fixture.rs diff --git a/crates/app/src/host/install_processors_tests.rs b/crates/presenter/src/host/install_processors_tests.rs similarity index 100% rename from crates/app/src/host/install_processors_tests.rs rename to crates/presenter/src/host/install_processors_tests.rs diff --git a/crates/app/src/host/install_tests.rs b/crates/presenter/src/host/install_tests.rs similarity index 100% rename from crates/app/src/host/install_tests.rs rename to crates/presenter/src/host/install_tests.rs diff --git a/crates/app/src/host/java.rs b/crates/presenter/src/host/java.rs similarity index 100% rename from crates/app/src/host/java.rs rename to crates/presenter/src/host/java.rs diff --git a/crates/app/src/host/job.rs b/crates/presenter/src/host/job.rs similarity index 100% rename from crates/app/src/host/job.rs rename to crates/presenter/src/host/job.rs diff --git a/crates/app/src/host/launch.rs b/crates/presenter/src/host/launch.rs similarity index 100% rename from crates/app/src/host/launch.rs rename to crates/presenter/src/host/launch.rs diff --git a/crates/app/src/host/launch_state_tests.rs b/crates/presenter/src/host/launch_state_tests.rs similarity index 100% rename from crates/app/src/host/launch_state_tests.rs rename to crates/presenter/src/host/launch_state_tests.rs diff --git a/crates/app/src/host/logs.rs b/crates/presenter/src/host/logs.rs similarity index 100% rename from crates/app/src/host/logs.rs rename to crates/presenter/src/host/logs.rs diff --git a/crates/app/src/host/logs_tests.rs b/crates/presenter/src/host/logs_tests.rs similarity index 100% rename from crates/app/src/host/logs_tests.rs rename to crates/presenter/src/host/logs_tests.rs diff --git a/crates/app/src/host/meter.rs b/crates/presenter/src/host/meter.rs similarity index 100% rename from crates/app/src/host/meter.rs rename to crates/presenter/src/host/meter.rs diff --git a/crates/app/src/host/mod.rs b/crates/presenter/src/host/mod.rs similarity index 98% rename from crates/app/src/host/mod.rs rename to crates/presenter/src/host/mod.rs index 5d72d65..f0d1485 100644 --- a/crates/app/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -1,7 +1,7 @@ //! 真实后端:把 core(game/launch/install/auth/java/diagnose/setting)装配成 GUI 的 host。 //! -//! 边界:本模块是整个 app 里**唯一**允许 `use unlucky_core::…` 的地方(连同 `settings_bridge`); -//! 出口只有 [`crate::dto`] 的快照/事件。线程模型: +//! 边界:本模块是**整个 presenter crate 里唯一**允许 `use unlucky_core::…` 的地方(连同 +//! `settings_bridge`);出口只有 [`crate::dto`] 的快照/事件。线程模型: //! //! ```text //! dispatch(AppCommand) ──► 立即动作(读盘/写设置)或 spawn 后台线程 diff --git a/crates/app/src/host/opener.rs b/crates/presenter/src/host/opener.rs similarity index 100% rename from crates/app/src/host/opener.rs rename to crates/presenter/src/host/opener.rs diff --git a/crates/app/src/host/settings.rs b/crates/presenter/src/host/settings.rs similarity index 100% rename from crates/app/src/host/settings.rs rename to crates/presenter/src/host/settings.rs diff --git a/crates/app/src/host/settings_tests.rs b/crates/presenter/src/host/settings_tests.rs similarity index 100% rename from crates/app/src/host/settings_tests.rs rename to crates/presenter/src/host/settings_tests.rs diff --git a/crates/app/src/host/versions.rs b/crates/presenter/src/host/versions.rs similarity index 100% rename from crates/app/src/host/versions.rs rename to crates/presenter/src/host/versions.rs diff --git a/crates/presenter/src/lib.rs b/crates/presenter/src/lib.rs new file mode 100644 index 0000000..d9e96b1 --- /dev/null +++ b/crates/presenter/src/lib.rs @@ -0,0 +1,117 @@ +//! Unlucky-Launcher 的 **UI 框架无关宿主层**(presenter)。 +//! +//! # 1. 这层为什么存在 +//! +//! D26 的技术路线是「用 iced 重写 UI」。重写只应该换掉**渲染**,不该重写业务装配—— +//! 本 crate 就是被两个 UI 版共用的那一半:Slint 版(`crates/app`)与 iced 版 +//! (`crates/app-iced`)都从这里取 DTO、状态 reducer 与 host。Slint 退役后只剩 iced +//! 依赖它,本 crate 不受影响。 +//! +//! 判据(M85 已核):这套代码原本住在 `crates/app/src`,但 `host/`(25 个文件)、 +//! `state.rs`、`settings_bridge.rs`、`dto/` 里**没有一处** UI 框架符号;它们依赖的只有 +//! 标准库、serde 与 `unlucky-core`。把它们留在 UI crate 里等于让 iced 重写跟着重写一遍 +//! 约 5400 行,白担行为漂移的风险。 +//! +//! # 2. 对外表面(UI 侧的唯一入口) +//! +//! ```text +//! host::CoreHost ──impl Backend──► dto::AppEvent ──► state::AppState::apply ──► dto 快照 +//! ▲ │ +//! └──────────── dto::AppCommand ◄────────────────┘ +//! ``` +//! +//! | 模块 | 入口 | UI 侧怎么用 | +//! | ---- | ---- | ----------- | +//! | [`dto`] | [`dto::Backend`] / [`dto::AppCommand`] / [`dto::AppEvent`] / 各 `*View` 快照 | 派发指令、消费事件、把快照字段映射成视图模型 | +//! | [`host`] | [`host::CoreHost`](实现 [`dto::Backend`])、[`host::HostEnv`] | 进程启动时构造一次,之后只当 `Box` 用 | +//! | [`state`] | [`state::AppState`](`apply` / `navigate` / 各页面字段) | **唯一**状态入口:事件进来先 `apply`,再从字段取值画 | +//! | [`prefs`] | `UiPrefs` / `ThemeMode` / `TileSize` / `AppearancePrefs` / `keys` | 读字段做展示;不自己写盘 | +//! | [`settings_bridge`] | `SettingsBridge`(`open` / `load` / `persist` / `reload_if_changed`) | 启动读一次、变更时写回、每拍问一次「变了吗」 | +//! | [`theme`] | `CONFIG_POLL_TICKS` / `BACKGROUND_KEY` 一类纯判据、`tile_viewport` / `system_theme_value` | 「这一拍该不该重读/重建」由它回答,UI 只执行 | +//! | [`router`] | `Route` / `Router` / `AiPanelMode` | 导航状态 | +//! | [`dialogs`] | `DialogQueue` / `DialogRequest` | 模态队列 | +//! | [`toast`] | `ToastCenter` / `Toast` | 非阻塞提示 | +//! | [`wizard`] | `WizardState` | 新建实例向导的纯状态机 | +//! | [`mock`] / [`mock_data`] | `MockBackend` / 样例数据 | 无数据根时的演示后端、UI 回归 | +//! | [`backend`] | `AppOptions` / `build_backend` / `config_path` | 启动时选真实 host 还是 mock | +//! +//! # 3. UI 侧允许做什么、禁止做什么 +//! +//! 允许(**只做展示与转发**): +//! +//! - 把本 crate 的值翻译成框架类型(视图模型、颜色、图标、字符串)并灌进属性/`Element`; +//! - 回调里把用户动作翻成 [`dto::AppCommand`] 交给 [`dto::Backend::dispatch`],再 `drain_events()` 喂 +//! [`state::AppState::apply`]——**不要**在本地做乐观回显(会话气泡/磁贴状态都以事件为准); +//! - 框架能力:窗口、定时器/订阅、图片解码与模糊层、截图落盘、字体加载。 +//! +//! 禁止: +//! +//! - 在本 crate 里出现 Slint / iced / winit 类型(守门命令见 `crates/app/Cargo.toml` 的注释: +//! 自指问题让那条命令不能写在本 crate 里,否则它自己就被抓出来了); +//! - UI 侧绕过 [`state::AppState::apply`] 直接改状态字段(会与事件流打架,磁贴/日志会漂); +//! - UI 侧直接 `use unlucky_core::…` 组装业务(读盘、起进程、拼路径、写设置一律走 host/bridge); +//! - UI 侧自己造 `` 路径:路径只从 [`host::HostEnv`] / [`settings_bridge`] 拿。 +//! +//! # 4. iced 接线的推荐用法(骨架,伪代码) +//! +//! ```ignore +//! // 1) 启动:选后端(数据根解析不出来自动退回 mock) +//! let backend: Box = build_backend(&AppOptions::default()); +//! let bridge = SettingsBridge::open(); // 读盘一次,主题/外观看这里 +//! let state = AppState::new(); +//! +//! // 2) 订阅:iced 的 30fps 订阅里推进一拍 +//! fn subscription() -> Subscription { iced::time::every(TICK).map(|_| Message::Tick) } +//! fn update(&mut self, Message::Tick) { +//! backend.tick(TICK); // 后台线程的消息在这里汇聚 +//! for event in backend.drain_events() { self.state.apply(event); } // 唯一的写状态入口 +//! if config_poll_due(self.ticks) { // presenter 的纯判据 +//! if let Some((appearance, theme)) = self.bridge.reload_if_changed() { … } +//! } +//! self.view = ViewModel::from(&self.state); // 展示转换在 iced 侧做 +//! } +//! +//! // 3) 回调:用户动作 → AppCommand → 事件回流(不本地回显) +//! fn update(&mut self, Message::Launch(id)) { self.dispatch(AppCommand::Launch(id)); } +//! ``` +//! +//! 要点:`state` / `bridge` / `backend` 三件由 UI 侧持有并驱动,`presenter` 不持有定时器也不 +//! 知道帧率;事件顺序以 `drain_events()` 的顺序为准(host 侧保证配对:`Install` 之后必有 +//! `InstallDone`、`LaunchStarted` 之后必有 `LaunchEnd`)。 +//! +//! # 5. 留在 `crates/app`(Slint 侧)的东西,以及为什么 +//! +//! | 留在 app | 为什么不能进 presenter | +//! | -------- | ---------------------- | +//! | `conv/` | 把 DTO 转成 **Slint 结构体**(UI 侧生成的视图模型类型),是 Slint 专属转换层 | +//! | `ui_bind.rs` / `page_sync.rs` / `callbacks*.rs` | 持有 `MainWindow`、`ModelRc`、回调闭包,全是框架绑定 | +//! | `theme_runtime.rs` / `appearance_bind.rs` | `impl Inner` 的方法,读写窗口属性与图片属性;其中的**纯判据**已抽到 [`theme`] | +//! | `bg_image.rs` / `png.rs` | 解码/模糊/编码产出框架图片类型与像素缓冲,属渲染侧 | +//! | `screenshot.rs` | Slint 快照通道(`Window::take_snapshot`) | +//! | `main.rs` | `include_modules!()` 与窗口拉起 | +//! +//! # 6. 零 UI 依赖的证据命令 +//! +//! 两条命令(`grep` 与 `cargo tree`)的原文与实测输出见 `crates/app/Cargo.toml` 的注释与 M85 +//! 的完成报告:本 crate 的依赖树里只有 serde / serde_json / unlucky-core(及其自身依赖)。 + +// 关于 dead_code:host 里有一批「已定义、由单测覆盖、等 iced 页面接线才调用」的辅助函数 +// (`host::logs::session_finished`、`host::settings::clear_vendor_priority`、`loader_label` +// 等)。它们原本住在 `crates/app` 里,由那边的 crate 级 `#![allow(dead_code)]` 一并放开; +// 搬进 lib 之后「pub 但模块私有」会被判成死码。逐条 `#[allow]` 会把噪音藏进实现细节, +// 这里沿用同一条策略统一放开,接线完成后再删掉本行暴露真死码。 +#![allow(dead_code)] + +pub mod backend; +pub mod dialogs; +pub mod dto; +pub mod host; +pub mod mock; +pub mod mock_data; +pub mod prefs; +pub mod router; +pub mod settings_bridge; +pub mod state; +pub mod theme; +pub mod toast; +pub mod wizard; diff --git a/crates/app/src/mock.rs b/crates/presenter/src/mock.rs similarity index 100% rename from crates/app/src/mock.rs rename to crates/presenter/src/mock.rs diff --git a/crates/app/src/mock_data.rs b/crates/presenter/src/mock_data.rs similarity index 100% rename from crates/app/src/mock_data.rs rename to crates/presenter/src/mock_data.rs diff --git a/crates/app/src/prefs.rs b/crates/presenter/src/prefs.rs similarity index 94% rename from crates/app/src/prefs.rs rename to crates/presenter/src/prefs.rs index 2649907..683aee4 100644 --- a/crates/app/src/prefs.rs +++ b/crates/presenter/src/prefs.rs @@ -10,8 +10,8 @@ pub use unlucky_core::setting::appearance::BgFill; use crate::dto::VersionCategory; -/// 预设强调色板(09 §104)。**必须**与 `ui/pages/settings-appearance.slint` 的 `swatch-colors` -/// 同序同值:Slint 侧只回传索引,十六进制串由这张表给出(一处真相)。 +/// 预设强调色板(09 §104)。**必须**与 UI 侧设置页「外观」分组的色板控件同序同值: +/// UI 侧只回传索引,十六进制串由这张表给出(一处真相)。 pub const ACCENT_PRESETS: [&str; 8] = [ "#0078D4", "#0F6CBD", "#107C10", "#9D5D00", "#C42B1C", "#8764B8", "#C239B3", "#00B7C3", ]; @@ -60,9 +60,9 @@ pub enum InstanceView { /// 主题三态(D17,P0 交付):`appearance.theme_mode ∈ {light, dark, auto}`,出厂 auto。 /// -/// auto 由 `theme/tokens.slint` 读 Slint 原生 `Palette.color-scheme` 跟随系统(后端随 OS 热更); -/// light/dark 由 `Theme.theme-mode` 钉死**自绘体系**;std-widgets 内部件(ListView 滚动条 / -/// ProgressIndicator / AboutSlint)目前仍跟随系统色,`Palette.color-scheme` 的钉死留 P1。 +/// `auto` = 跟随系统(由 UI 侧的令牌层读系统调色板,随 OS 热更;探测口径见 +/// [`crate::theme::system_theme_value`]);`light` / `dark` 钉死**自绘体系**的取值。 +/// 三个取值怎么变成框架颜色是 UI 侧的事,本 crate 只给语义。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ThemeMode { /// 跟随系统(默认) diff --git a/crates/app/src/router.rs b/crates/presenter/src/router.rs similarity index 100% rename from crates/app/src/router.rs rename to crates/presenter/src/router.rs diff --git a/crates/app/src/settings_bridge.rs b/crates/presenter/src/settings_bridge.rs similarity index 100% rename from crates/app/src/settings_bridge.rs rename to crates/presenter/src/settings_bridge.rs diff --git a/crates/app/src/state.rs b/crates/presenter/src/state.rs similarity index 97% rename from crates/app/src/state.rs rename to crates/presenter/src/state.rs index c389eed..c928460 100644 --- a/crates/app/src/state.rs +++ b/crates/presenter/src/state.rs @@ -1,6 +1,6 @@ -//! UI 状态与 reducer:Slint 只负责渲染,所有状态变更都汇聚到 [`AppState::apply`]。 +//! UI 状态与 reducer:UI 框架只负责渲染,所有状态变更都汇聚到 [`AppState::apply`]。 //! -//! 数据流向:mock/host 后端 → [`crate::dto::AppEvent`] 队列 → `apply` → 状态字段 → Slint 属性。 +//! 数据流向:mock/host 后端 → [`crate::dto::AppEvent`] 队列 → `apply` → 状态字段 → UI 属性。 //! 偏好项在 [`crate::prefs`] 里登记了持久化键名,core::setting 的 schema 落地后按这些键读写。 use crate::dialogs::DialogQueue; @@ -56,7 +56,7 @@ impl AiSession { } } -/// 磁贴网格单元:位置由 [`grid_positions`] 算好,Slint 只消费 row/col。 +/// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct TileCell<'a> { /// 实例数据 @@ -69,7 +69,7 @@ pub struct TileCell<'a> { /// 纯函数:把 n 个磁贴摊到 `columns` 列的网格上,返回 `(row, col)`。 /// -/// 特意放在 Rust 侧:Slint DSL 里做整数除法会引入隐式浮点,且这段逻辑可以单测。 +/// 特意放在 Rust 侧:声明式 UI 的表达式里做整数除法容易引出隐式浮点,且这段逻辑可以单测。 pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> { let columns = columns.max(1); (0..count) diff --git a/crates/app/src/state_tests.rs b/crates/presenter/src/state_tests.rs similarity index 100% rename from crates/app/src/state_tests.rs rename to crates/presenter/src/state_tests.rs diff --git a/crates/presenter/src/theme.rs b/crates/presenter/src/theme.rs new file mode 100644 index 0000000..42d39f2 --- /dev/null +++ b/crates/presenter/src/theme.rs @@ -0,0 +1,165 @@ +//! 主题与外观的**纯**判据:轮询节拍、背景图缓存键与量化、系统主题取值。 +//! +//! 这一层原本混在 `crates/app` 的 `ui_bind.rs` / `theme_runtime.rs` 里,与「把值灌进窗口属性」 +//! 的绑定代码缠在一起,于是两个 UI 版都得各写一遍。抽到这里的只有**判据**:什么时候该重读配置、 +//! 什么时候该重建背景图、系统主题该报哪个值。灌属性仍然由 UI 侧做,本模块不碰任何框架类型。 + +use crate::prefs::BgFill; + +/// 配置热切轮询间隔(拍数,33ms/拍 → 约 1s 一次 `mtime` 比对,AI/CLI 改完下一拍即生效)。 +pub const CONFIG_POLL_TICKS: u64 = 30; + +/// 系统主题兜底轮询间隔(拍数):只在 `auto` 且后端不认识系统主题时启动(约 3s)。 +pub const SYSTEM_THEME_POLL_TICKS: u64 = 90; + +/// tile 模式重建画布的窗口尺寸粒度(像素):避免拖动窗口时反复重算平铺画布。 +pub const TILE_REGENERATE_STEP: u32 = 128; + +/// 系统主题取值(UI 侧根组件属性就用这三个值:0 未知 / 1 浅色 / 2 深色)。 +pub const SYSTEM_THEME_UNKNOWN: i32 = 0; +/// 浅色。 +pub const SYSTEM_THEME_LIGHT: i32 = 1; +/// 深色。 +pub const SYSTEM_THEME_DARK: i32 = 2; + +/// 背景图加工输入(值相同就不重新解码)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackgroundKey { + /// 背景图路径 + pub path: String, + /// 填充模式 + pub fill: BgFill, + /// tile 用的窗口尺寸(按 [`TILE_REGENERATE_STEP`] 量化;cover/contain 恒为 (0, 0)) + pub viewport: (u32, u32), +} + +/// 本拍是否该比对一次配置文件 `mtime`(热切的采样点)。 +pub fn config_poll_due(ticks: u64) -> bool { + ticks.is_multiple_of(CONFIG_POLL_TICKS) +} + +/// 本拍是否该兜底探测系统主题。 +/// +/// 启动时先探一次(`probed == false`),之后只在「主题是 auto + 后端不认识系统主题」时 +/// 按 [`SYSTEM_THEME_POLL_TICKS`] 兜底轮询。 +pub fn system_theme_poll_due(probed: bool, auto: bool, backend_known: bool, ticks: u64) -> bool { + !probed || (auto && !backend_known && ticks.is_multiple_of(SYSTEM_THEME_POLL_TICKS)) +} + +/// 系统主题取值(纯映射,便于单测)。 +pub fn system_theme_code(theme: unlucky_core::platform::theme_probe::SystemTheme) -> i32 { + use unlucky_core::platform::theme_probe::SystemTheme; + match theme { + SystemTheme::Light => SYSTEM_THEME_LIGHT, + SystemTheme::Dark => SYSTEM_THEME_DARK, + SystemTheme::Unknown => SYSTEM_THEME_UNKNOWN, + } +} + +/// 探测本机系统主题并给出 UI 侧属性值。 +pub fn system_theme_value() -> i32 { + system_theme_code(unlucky_core::platform::theme_probe::probe()) +} + +/// tile 画布的窗口尺寸(量化到 [`TILE_REGENERATE_STEP`],避免拖动窗口时反复重算)。 +/// +/// 非 tile 模式恒为 (0, 0):cover/contain 不依赖窗口尺寸,缩放只让渲染器拉伸。 +pub fn tile_viewport(fill: BgFill, size: (u32, u32)) -> (u32, u32) { + if fill != BgFill::Tile { + return (0, 0); + } + ( + size.0 / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP, + size.1 / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP, + ) +} + +#[cfg(test)] +mod tests { + use super::{ + config_poll_due, system_theme_code, system_theme_poll_due, tile_viewport, BackgroundKey, + CONFIG_POLL_TICKS, SYSTEM_THEME_DARK, SYSTEM_THEME_LIGHT, SYSTEM_THEME_POLL_TICKS, + SYSTEM_THEME_UNKNOWN, TILE_REGENERATE_STEP, + }; + use crate::prefs::BgFill; + use unlucky_core::platform::theme_probe::SystemTheme; + + #[test] + fn config_poll_fires_every_interval() { + assert!(config_poll_due(0)); + assert!(!config_poll_due(1)); + assert!(config_poll_due(CONFIG_POLL_TICKS)); + assert!(!config_poll_due(CONFIG_POLL_TICKS + 1)); + } + + #[test] + fn system_theme_probe_backs_off_after_first_read() { + // 启动时无条件探一次 + assert!(system_theme_poll_due(false, false, true, 1)); + // 探过、主题非 auto:不再轮询 + assert!(!system_theme_poll_due( + true, + false, + false, + SYSTEM_THEME_POLL_TICKS + )); + // 探过、auto、后端认识系统主题:不再轮询 + assert!(!system_theme_poll_due( + true, + true, + true, + SYSTEM_THEME_POLL_TICKS + )); + // 探过、auto、后端不认识:按间隔兜底 + assert!(!system_theme_poll_due( + true, + true, + false, + SYSTEM_THEME_POLL_TICKS - 1 + )); + assert!(system_theme_poll_due( + true, + true, + false, + SYSTEM_THEME_POLL_TICKS + )); + } + + #[test] + fn system_theme_codes_are_stable() { + assert_eq!( + system_theme_code(SystemTheme::Unknown), + SYSTEM_THEME_UNKNOWN + ); + assert_eq!(system_theme_code(SystemTheme::Light), SYSTEM_THEME_LIGHT); + assert_eq!(system_theme_code(SystemTheme::Dark), SYSTEM_THEME_DARK); + } + + #[test] + fn tile_viewport_quantizes_and_ignores_other_fills() { + assert_eq!(tile_viewport(BgFill::Cover, (1180, 760)), (0, 0)); + assert_eq!(tile_viewport(BgFill::Contain, (1180, 760)), (0, 0)); + let (width, height) = tile_viewport(BgFill::Tile, (1180, 760)); + assert_eq!(width % TILE_REGENERATE_STEP, 0); + assert_eq!(height % TILE_REGENERATE_STEP, 0); + assert_eq!((width, height), (1152, 768)); + } + + #[test] + fn background_key_compares_by_value() { + let key = BackgroundKey { + path: "a.png".to_string(), + fill: BgFill::Tile, + viewport: (256, 256), + }; + assert_eq!(key.clone(), key); + assert_ne!( + key, + BackgroundKey { + path: "a.png".to_string(), + fill: BgFill::Tile, + viewport: (128, 256), + } + ); + } +} diff --git a/crates/app/src/toast.rs b/crates/presenter/src/toast.rs similarity index 100% rename from crates/app/src/toast.rs rename to crates/presenter/src/toast.rs diff --git a/crates/app/src/wizard.rs b/crates/presenter/src/wizard.rs similarity index 100% rename from crates/app/src/wizard.rs rename to crates/presenter/src/wizard.rs