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` 现在无输出。
164 lines
5.6 KiB
Rust
164 lines
5.6 KiB
Rust
//! 实例相关:磁贴外观(相位/进度/徽标/占位色)、运行中列表。
|
|
|
|
use slint::Color;
|
|
|
|
use crate::{LoaderKind, RuntimePhase, UiTile};
|
|
use unlucky_presenter::dto::{self, InstanceRuntime};
|
|
use unlucky_presenter::state::{AppState, TileCell};
|
|
|
|
/// 头像/图标占位的底色池(Metro 强调色系,按 id 哈希稳定取色)。
|
|
const HUES: [u32; 8] = [
|
|
0x0078D4, 0x8764B0, 0xD13438, 0xCA5010, 0x498205, 0x038387, 0x5C2D91, 0x0050EF,
|
|
];
|
|
|
|
/// 加载器枚举。
|
|
pub fn loader(kind: dto::LoaderKind) -> LoaderKind {
|
|
match kind {
|
|
dto::LoaderKind::Vanilla => LoaderKind::Vanilla,
|
|
dto::LoaderKind::Fabric => LoaderKind::Fabric,
|
|
dto::LoaderKind::Quilt => LoaderKind::Quilt,
|
|
dto::LoaderKind::Forge => LoaderKind::Forge,
|
|
dto::LoaderKind::NeoForge => LoaderKind::Neoforge,
|
|
dto::LoaderKind::OptiFine => LoaderKind::Optifine,
|
|
dto::LoaderKind::LiteLoader => LoaderKind::Liteloader,
|
|
}
|
|
}
|
|
|
|
/// 运行时状态 → (磁贴外观相位, 进度 0~1, 阶段文案)。
|
|
pub fn phase(runtime: &InstanceRuntime) -> (RuntimePhase, f32, String) {
|
|
match runtime {
|
|
InstanceRuntime::Idle => (RuntimePhase::Idle, 0.0, String::new()),
|
|
InstanceRuntime::Busy { stage, progress } => {
|
|
let phase = match stage {
|
|
dto::LaunchStage::Download => RuntimePhase::Downloading,
|
|
dto::LaunchStage::Verify => RuntimePhase::Verifying,
|
|
dto::LaunchStage::Launch => RuntimePhase::Launching,
|
|
dto::LaunchStage::Window => RuntimePhase::Running,
|
|
};
|
|
(phase, progress.ratio(), stage.label().to_string())
|
|
}
|
|
InstanceRuntime::Running { .. } => (RuntimePhase::Running, 1.0, "运行中".to_string()),
|
|
InstanceRuntime::Failed { reason } => (RuntimePhase::Failed, 0.0, reason.clone()),
|
|
}
|
|
}
|
|
|
|
/// 名字首字(无图标时的占位字母)。
|
|
pub fn initial_of(name: &str) -> String {
|
|
name.chars()
|
|
.next()
|
|
.map(|c| c.to_string())
|
|
.unwrap_or_else(|| "?".to_string())
|
|
}
|
|
|
|
/// id → 稳定底色(同一实例每次启动同色)。
|
|
pub fn hue_of(id: &str) -> Color {
|
|
let hash = id.bytes().fold(0u32, |acc, byte| {
|
|
acc.wrapping_mul(31).wrapping_add(u32::from(byte))
|
|
});
|
|
Color::from_argb_encoded(0xFF00_0000 | HUES[(hash as usize) % HUES.len()])
|
|
}
|
|
|
|
/// ISO-8601 UTC → 逗号少的本地展示串(`2026-10-29 19:22`);None/空 → 从未启动。
|
|
pub fn last_played(raw: &Option<String>) -> String {
|
|
match raw {
|
|
None => "从未启动".to_string(),
|
|
Some(value) if value.is_empty() => "从未启动".to_string(),
|
|
Some(value) => {
|
|
let trimmed = value.trim_end_matches('Z');
|
|
let mut out = String::with_capacity(trimmed.len());
|
|
let mut seen_time = false;
|
|
for ch in trimmed.chars() {
|
|
match ch {
|
|
'T' => {
|
|
seen_time = true;
|
|
out.push(' ');
|
|
}
|
|
':' if seen_time && out.ends_with(|c: char| c.is_ascii_digit()) => {
|
|
// 只保留到分钟:秒(第三段冒号之后)直接截断
|
|
if out.matches(':').count() >= 1 {
|
|
break;
|
|
}
|
|
out.push(ch);
|
|
}
|
|
_ => out.push(ch),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 单个磁贴。
|
|
pub fn tile(cell: &TileCell<'_>) -> UiTile {
|
|
let instance = cell.instance;
|
|
let (phase, progress, label) = phase(&instance.runtime);
|
|
UiTile {
|
|
id: instance.id.clone().into(),
|
|
name: instance.name.clone().into(),
|
|
mc_version: instance.mc_version.clone().into(),
|
|
loader: loader(instance.loader.kind),
|
|
loader_version: instance.loader.version.clone().into(),
|
|
loader_label: instance.loader.badge().into(),
|
|
last_played: last_played(&instance.last_played).into(),
|
|
phase,
|
|
phase_label: label.into(),
|
|
progress,
|
|
icon: slint::Image::default(),
|
|
hue: hue_of(&instance.id),
|
|
initial: initial_of(&instance.name).into(),
|
|
row: i32::try_from(cell.row).unwrap_or(0),
|
|
col: i32::try_from(cell.col).unwrap_or(0),
|
|
}
|
|
}
|
|
|
|
/// 磁贴网格(已按当前尺寸算好 row/col)。
|
|
pub fn tiles(state: &AppState) -> Vec<UiTile> {
|
|
state.tiles().iter().map(tile).collect()
|
|
}
|
|
|
|
/// 运行中实例(顶部迷你状态条)。
|
|
pub fn running_tiles(state: &AppState) -> Vec<UiTile> {
|
|
state
|
|
.running()
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, instance)| {
|
|
let cell = TileCell {
|
|
instance,
|
|
row: 0,
|
|
col: index,
|
|
};
|
|
tile(&cell)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{hue_of, initial_of, last_played};
|
|
|
|
#[test]
|
|
fn initial_is_first_grapheme_or_question_mark() {
|
|
assert_eq!(initial_of("生存整合包"), "生");
|
|
assert_eq!(initial_of("Vanilla"), "V");
|
|
assert_eq!(initial_of(""), "?");
|
|
}
|
|
|
|
#[test]
|
|
fn hue_is_stable_per_id() {
|
|
assert_eq!(hue_of("survival"), hue_of("survival"));
|
|
// 不同 id 大体不同色(哈希池 8 色,这里只验证确定性 + 不 panic)
|
|
let _ = hue_of("another-instance");
|
|
}
|
|
|
|
#[test]
|
|
fn last_played_formats_iso_to_minutes() {
|
|
assert_eq!(
|
|
last_played(&Some("2026-10-29T19:22:45Z".to_string())),
|
|
"2026-10-29 19:22"
|
|
);
|
|
assert_eq!(last_played(&None), "从未启动");
|
|
assert_eq!(last_played(&Some(String::new())), "从未启动");
|
|
}
|
|
}
|