Unlucky Launcher v0.2.1
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s

Single-commit source snapshot of the v0.2.1 release.
This commit is contained in:
AskaEth committed 2026-11-06 12:35:56 +08:00
commit d517f28140
1092 files changed
+211068

No files matched your search

@@ -0,0 +1,238 @@
//! 内容库页的类型:页面自留状态 / 四态 / 展示行 / 目标实例 / 装不上的原因。
//!
//! 从 `pages/content/model.rs` 拆出(那个文件已贴 400 行硬上限)。纯数据类型与纯函数,
//! 不认识 iced:`model/tests.rs` 直接钉它们的判定。
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSource, InstanceSummary, LoaderKind};
use unlucky_i18n::{Catalog, Key};
/// 页面自留的选择(检索条件与结果在 presenter 的 `AppState::content`)。
#[derive(Debug, Clone, PartialEq)]
pub struct PageState {
/// 当前内容类型
pub kind: ContentKind,
/// 选中的项目 id(`None` = 没选 → 详情面板空态)
pub selected: Option<String>,
/// 目标实例 id(`None` = 没选 → 装入按钮禁用并说明)
pub target: Option<String>,
/// 关键词输入框里的草稿(**未提交**;提交后才进 `AppState::content.query`)
pub draft: String,
/// 导入/导出区的页内选择(M113:路径草稿 / 目标实例 / 格式 / 展开的那条冲突)
pub pack: super::super::packui::PackPageState,
}
impl Default for PageState {
fn default() -> Self {
Self {
kind: ContentKind::Mod,
selected: None,
target: None,
draft: String::new(),
pack: super::super::packui::PackPageState::default(),
}
}
}
/// 页面此刻画哪一态(四态 + 分页尾)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
/// 加载中(首屏 / 换条件 / 换页)
Loading,
/// 有命中项
Ready,
/// 拉到了但一条都没有
Empty,
/// 失败(带一句人读原因)
Failed(String),
}
impl Status {
/// 失败了吗(视图用它决定画错误面板还是列表)。
pub const fn failed(&self) -> bool {
matches!(self, Self::Failed(_))
}
}
/// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hit {
/// 项目 id(选中 / 装入指令的载荷)
pub id: String,
/// 标题
pub title: String,
/// 一行说明
pub summary: String,
/// 来源 + 作者(`Modrinth · jelly`)
pub supporting: String,
/// 下载量(已加千分位)
pub downloads: String,
/// 更新日期的人读串
pub updated: String,
/// 支持的游戏版本(详情面板用;空 = 站点没给)
pub versions: Vec<String>,
/// 支持的加载器(**站点原样的小写 facet**:兼容性判定要用它跟 `loader_facet` 比)
pub loaders: Vec<String>,
/// 来源(行上标来源徽标)
pub source: ContentSource,
}
impl Hit {
/// 命中项 DTO → 展示态(**演示数据也走这条**,于是 fixture 与产品路径逐字段同源)。
pub(crate) fn of(hit: &ContentHit, catalog: Catalog) -> Self {
let source = match hit.source {
ContentSource::Modrinth => catalog.text(Key::ContentSourceModrinth),
ContentSource::Curseforge => catalog.text(Key::ContentSourceCurseforge),
};
let supporting = if hit.author.is_empty() {
source.to_string()
} else {
format!("{source} · {}", hit.author)
};
Self {
id: hit.id.clone(),
title: hit.title.clone(),
summary: hit.summary.clone(),
supporting,
downloads: catalog.render(
Key::ContentDetailDownloads,
&[("count", &thousands(hit.downloads))],
),
updated: catalog.render(Key::ContentDetailUpdated, &[("date", &hit.updated)]),
versions: hit.game_versions.clone(),
loaders: hit.loaders.clone(),
source: hit.source,
}
}
}
/// 目标实例(一键装入的对象)的展示态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
/// 实例 id
pub id: String,
/// 显示名
pub name: String,
/// 游戏版本
pub mc_version: String,
/// 加载器
pub loader: LoaderKind,
}
impl Target {
pub(super) fn of(instance: &InstanceSummary) -> Self {
Self {
id: instance.id.clone(),
name: instance.name.clone(),
mc_version: instance.mc_version.clone(),
loader: instance.loader.kind,
}
}
/// 详情里那一行(`Survival · 1.20.4 · Fabric`)。
pub(crate) fn label(&self) -> String {
format!(
"{} · {} · {}",
self.name,
self.mc_version,
loader_name(self.loader)
)
}
}
/// 下载量加千分位(`1200000` → `1,200,000`):raw 数字在详情面板上读不出量级。
pub(crate) fn thousands(value: u64) -> String {
let digits = value.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (index, ch) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
out.push(',');
}
out.push(ch);
}
out
}
/// 首字母大写(`fabric` → `Fabric`;空串原样)。
///
/// **只在画的时候用**:`Hit::loaders` 与兼容性判定都保持站点原样的小写 facet,
/// 否则「Fabric」与 facet「fabric」一比就成了不兼容(本轮踩过)。
pub(crate) fn capitalize(raw: &str) -> String {
let mut chars = raw.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// 加载器的**界面名**(ASCII)。
///
/// 不复用 `LoaderKind::label()`:它对原版返回「原版」——中文业务串,而本仓的界面文案在 i18n
/// 之前是英文基线(干净 CI 上没有中文字体,见 `pages/instances/data.rs` 的同一条理由)。
pub const fn loader_name(loader: LoaderKind) -> &'static str {
match loader {
LoaderKind::Vanilla => "Vanilla",
LoaderKind::Fabric => "Fabric",
LoaderKind::Quilt => "Quilt",
LoaderKind::Forge => "Forge",
LoaderKind::NeoForge => "NeoForge",
LoaderKind::OptiFine => "OptiFine",
LoaderKind::LiteLoader => "LiteLoader",
}
}
/// 选中项的**判定事实**(版本 / 加载器 / 来源)。
///
/// 为什么另起一个结构:兼容性判据随着「宿主接通了什么」在长(M109 加了来源),
/// 每次加一条都改一次 `blockers_of` 的形参列表,而调用点有两处(产品路径与演示数据)。
#[derive(Debug, Clone, Copy)]
pub(crate) struct HitFacts<'a> {
/// 支持的游戏版本(空 = 站点没给)
pub versions: &'a [String],
/// 支持的加载器(空 = 站点没给;**站点原样的小写 facet**)
pub loaders: &'a [String],
/// 来源(决定「文件端点通没通」)
pub source: ContentSource,
}
/// 装不上的原因(**一条一个原因**,可以有多条同时成立)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Blocker {
/// 整包类内容不走「单文件进目录」这条路(导入管线还没落地)
NeedsPipeline,
/// 这个来源的**文件端点**还没接通(检索通了、拿到下载地址那一跳没通)
SourceNotWired,
/// 项目不支持目标实例的游戏版本
Version {
/// 项目支持的游戏版本(人读,最多取三个)
needs: String,
/// 目标实例的版本
actual: String,
},
/// 项目不支持目标实例的加载器
Loader {
/// 目标实例的加载器
loader: LoaderKind,
},
/// 没有目标实例
NoTarget,
}
impl Blocker {
/// 一句话原因(i18n)。
pub(super) fn reason(&self, catalog: Catalog) -> String {
match self {
Self::NeedsPipeline => catalog.text(Key::ContentModpackNotReady).to_string(),
Self::Version { needs, actual } => catalog.render(
Key::ContentIncompatibleVersion,
&[("version", needs), ("actual", actual)],
),
Self::Loader { loader } => catalog.render(
Key::ContentIncompatibleLoader,
&[("loader", loader_name(*loader))],
),
Self::SourceNotWired => catalog.text(Key::ContentSourceNotWired).to_string(),
Self::NoTarget => catalog.text(Key::ContentNoInstances).to_string(),
}
}
}