feat(presenter): 新建 unlucky-presenter,把 host/状态/DTO/设置桥从 app 迁出(M85 第 1 块)
D26(iced 重写)的关键一次性动作:UI 框架无关的宿主层独立成 crate, Slint 版(crates/app)与 iced 版(crates/app-iced)共用,Slint 退役后不再重写一遍。 - 新建 crates/presenter(package `unlucky-presenter` / lib `unlucky_presenter`), workspace 登记 + crate 文档头写清「这层为什么存在」、对外表面、UI 侧允许/禁止、 iced 接线骨架、以及哪些东西留在了 crates/app 及为什么。 - `git mv`(保留文件历史)迁入:host/(25 文件)、dto/(11 文件)、state.rs(+tests)、 prefs.rs、settings_bridge.rs、toast.rs、router.rs、dialogs.rs、wizard.rs、 mock.rs、mock_data.rs、backend.rs。模块路径与公开 API 名称不变(`host::install` 等), 内部实现零改动。 - 新增 `theme`:把原本混在 ui_bind/theme_runtime 里的**纯判据**(CONFIG_POLL_TICKS / SYSTEM_THEME_POLL_TICKS / TILE_REGENERATE_STEP / BackgroundKey / tile_viewport / system_theme_value / 两个 poll 判据)抽出来,附 5 个单测;app 侧改成调用它们。 - crates/app 改为消费者:删掉迁走的文件,`crate::host|state|dto|prefs|settings_bridge| toast|router|dialogs|wizard|mock|mock_data|backend` 全部改写为 `unlucky_presenter::*` (64 处),backend.rs 的 config_path/build_backend 提为 pub。 - crates/app-iced 只登记 unlucky-presenter 依赖,本 mission 不接线。 - prefs.rs 的 2 处 slint 文档引用改写;`grep -rn slint crates/presenter` 现在无输出。
This commit is contained in:
1 parent
00f132d8ce
commit
f159529936
69 files changed
+441
-129
No files matched your search
Generated
+11
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 运行时)。
|
||||
|
||||
+11
-2
@@ -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` …)。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Inner>) {
|
||||
@@ -181,7 +181,7 @@ fn wire_instances(ui: &crate::MainWindow, weak: &Weak<Inner>) {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Inner>) {
|
||||
@@ -137,7 +137,7 @@ pub(crate) fn wire_pages(weak: Weak<Inner>) {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<UiChatMessage> {
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -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<UiLogSession> {
|
||||
|
||||
@@ -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<SharedString> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+2
-14
@@ -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!();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 退化为单块)。
|
||||
|
||||
+12
-30
@@ -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<Inner>,
|
||||
@@ -57,15 +48,6 @@ pub(crate) struct Inner {
|
||||
pub(crate) system_theme_probed: Cell<bool>,
|
||||
}
|
||||
|
||||
/// 背景图加工输入(值相同就不重新解码)。
|
||||
#[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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -17,12 +17,12 @@ pub struct AppOptions {
|
||||
}
|
||||
|
||||
/// `<root>/.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<dyn Backend> {
|
||||
pub fn build_backend(options: &AppOptions) -> Box<dyn Backend> {
|
||||
if options.mock {
|
||||
return Box::new(MockBackend::new());
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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 断言。
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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 后台线程
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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<dyn Backend>` 用 |
|
||||
//! | [`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 侧自己造 `<data root>` 路径:路径只从 [`host::HostEnv`] / [`settings_bridge`] 拿。
|
||||
//!
|
||||
//! # 4. iced 接线的推荐用法(骨架,伪代码)
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // 1) 启动:选后端(数据根解析不出来自动退回 mock)
|
||||
//! let backend: Box<dyn Backend> = build_backend(&AppOptions::default());
|
||||
//! let bridge = SettingsBridge::open(); // 读盘一次,主题/外观看这里
|
||||
//! let state = AppState::new();
|
||||
//!
|
||||
//! // 2) 订阅:iced 的 30fps 订阅里推进一拍
|
||||
//! fn subscription() -> Subscription<Message> { 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;
|
||||
File renamed without changes.
File renamed without changes.
@@ -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 {
|
||||
/// 跟随系统(默认)
|
||||
File renamed without changes.
File renamed without changes.
@@ -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)
|
||||
File renamed without changes.
@@ -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),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user