Files
Unlucky-Launcher/crates/app/src/conv/instances.rs
T
AskaEth f159529936 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` 现在无输出。
2026-11-02 09:29:31 +08:00

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())), "从未启动");
}
}