Files
Unlucky-Launcher/crates/app-iced/src/pages/settings/java/view.rs
T
AskaEth ce463cc9b5 feat(app-iced): Java 页「下载一个 JDK」+ 三个设置键(M197)
用户原话:「我们内置了那么多镜像源 甚至用户不能手动选择指定版本下载。」
「自动下载 jdk17 作为基准版本,后续在设置中提供多版本可供用户下载。」

- core:新增 `game::java::catalog`(8/11/17/21/25 五档 × 能承接的发行版 × 「该装哪档」),
  数据全部来自 `JdkVendor::supports` 与 `vendor::order`,不复制第二份真相。
- progress:`SourceFailed` 补一个结构化的 `kind`(在这之前只有中文 `reason`,双语界面
  要么放弃语言、要么放弃「哪个源为什么退避」这条用户可见信息)。
- app-iced:`downloads` 模块真下(后台线程 + `ProgressEmitter` + 快照),三态(选择列表 /
  下载中 / 失败)都能说出「命中的源 / 退避原因 / 试过哪些源」;成功后重扫并(在
  `java.auto_install` 开着且没有默认 JDK 时)把新装的那份设为默认。
- 三个设置键做成可改 UI:`java.installRoot` / `java.vendorPriority` / `java.auto_install`,
  各自带「现在生效什么、从哪一层来」(数据目录模式 / 地区默认序),改完回读落盘值。
- `State::satisfied` 换成带方向的 `Fit`:1.12.2 + Java 21 不再被说成「低于要求」。
- i18n:删掉 `SettingsJavaEmptyBody` / `SettingsJavaAddHint` 的 markdown 反引号;
  `SettingsJavaNeverScannedBody` 改成不与首屏自动扫矛盾的表述。
- 出图三张(选择列表 / 下载中 / 失败)在 DevDocs/artifacts/m197/。

顺带拆文件守 400 行:`golden/tail.rs` 的 M174 段搬到 `golden/tail_ai.rs`,
`pages/settings/java/{context,prefs,prefs_store,downloads,download_view}` 各自成模块。
2026-11-08 20:49:56 +08:00

293 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 「Java」分组的一张卡片(M175)。
//!
//! ## 版面(自上而下)
//!
//! | 行 | 内容 | 数值 / 证据 |
//! |---|---|---|
//! | 状态 | 未检测到 / N 个 JDK / 正在扫描 3/7 | 扫描按钮与取消按钮同步禁用 |
//! | 运行时列表 | 每个 JDK 一行:路径 + 版本 + 发行方 + 来源 | 「默认」标记 / 「设为默认」chip |
//! | 版本门槛 | 当前实例需要 Java ≥ N,哪一行不满足 | 来自 app 层算好的 [`Context`] |
//! | 手动添加 | 输入框 + 「添加」 | 校验不过时下面直接说原因 |
//! | 默认内存 | 数字框 + 范围提示 + 「现在生效多少来自哪一层」 | 落盘值(不是草稿) |
//! | JVM 参数 | 文本框 + 拼命令行提示 | 落盘的 `java.extra_args` |
//! | 动作 | 保存 | 灰掉时下面是原因 |
//!
//! 文案一律走 [`unlucky_i18n::Catalog`]:本组要 `Lang`,所以走 `settings/mod.rs` 的按下标分发
//! (与 General / Update / Assistant 同一条路),`Group.body` 保持 `None`。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame::{card, divider, text_role_owned};
use crate::pages::Message;
use crate::theme::Ui;
use super::fields::{actions_row, add_row, hint_line, jvm_field, memory_field, notice_line};
use super::state::{Progress, RuntimeRow, State};
use super::Message as JavaMessage;
use crate::pages::settings::{GroupMessage, States};
/// 一条本组消息 → 页面消息。
pub(super) fn msg(message: JavaMessage) -> Message {
Message::Settings(GroupMessage::Java(message))
}
/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来)。
pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> {
body_of(ui, &states.groups.java, lang)
}
fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
stacked = stacked.push(status_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(runtime_list(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(add_row(ui, catalog, state));
// M197:真下载一个 JDK(版本选择列表 / 下载中 / 失败三态都在这一块里)。
stacked = stacked.push(divider(ui));
stacked = stacked.push(super::download_view::section(ui, catalog, state));
// M197:三个设置键(安装目录 / 发行版顺序 / 缺 JDK 时自动准备)。
stacked = stacked.push(divider(ui));
stacked = stacked.push(super::prefs_view::section(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(memory_field(ui, catalog, state));
stacked = stacked.push(jvm_field(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(actions_row(ui, catalog, state));
stacked = stacked.push(hint_line(ui, catalog, state));
if let Some(notice) = &state.notice {
stacked = stacked.push(notice_line(ui, catalog, notice));
}
card(ui, stacked)
}
/// 状态行:未检测到 / N 个 JDK / 正在扫描 / 正在取消 + 两个按钮。
///
/// 「取消」的语义(r1 复审 P2-5):按下之后到线程真退出之间,界面显示的是**正在取消…**,
/// 「立即扫描」保持禁用——否则用户能在旧线程还活着的时候起第二个。
fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
let (text, scanning) = match (state.scanning, state.scanned) {
(Some(_), _) if state.cancelled => {
(catalog.text(Key::SettingsJavaCancelling).to_string(), true)
}
// 候选表还在收集(`total == 0`):不显示 `0/0`,给一句「正在扫描…」。
(Some(Progress { total: 0, .. }), _) => (
catalog.text(Key::SettingsJavaScanningStart).to_string(),
true,
),
(Some(Progress { done, total }), _) => (
catalog.render(
Key::SettingsJavaScanning,
&[
("done", done.to_string().as_str()),
("total", total.to_string().as_str()),
],
),
true,
),
(None, true) if state.runtimes.is_empty() => {
(catalog.text(Key::SettingsJavaEmptyTitle).to_string(), false)
}
(None, true) => (
catalog.render(
Key::SettingsJavaScanDone,
&[("count", state.runtimes.len().to_string().as_str())],
),
false,
),
(None, false) => (
catalog.text(Key::SettingsJavaNeverScanned).to_string(),
false,
),
};
let scan_label = catalog.text(Key::SettingsJavaScan);
let scan = if scanning || !state.storage {
buttons::filled(ui, Button::disabled(scan_label))
} else {
buttons::filled(ui, Button::new(scan_label, msg(JavaMessage::Scan)))
};
// 已经按过取消(线程还没退出)时,「取消」也灰掉:再按一次没有任何意义。
let cancel = buttons::tonal(
ui,
if scanning && !state.cancelled {
Button::new(
catalog.text(Key::SettingsJavaCancel),
msg(JavaMessage::CancelScan),
)
} else {
Button::disabled(catalog.text(Key::SettingsJavaCancel))
},
);
// 「扫描已取消」那句只在线程**真退出之后**出现;退出之前状态行本身写着「正在取消…」。
let cancelled = (state.cancelled && state.scanning.is_none()).then(|| {
text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsJavaCancelled).to_string(),
ui.color(Role::OnSurfaceVariant),
)
});
let mut block = column![row![
container(text_role_owned(
ui,
TypeRole::BodyMedium,
true,
text,
ui.color(if state.scanning.is_some() {
Role::Primary
} else {
Role::OnSurface
}),
))
.width(Length::Fill),
scan,
cancel,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center),]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if let Some(line) = cancelled {
block = block.push(line);
}
block.into()
}
/// 运行时列表:每行一个 JDK,带「默认」标记与「设为默认」chip。
fn runtime_list<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let mut list = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
if state.runtimes.is_empty() {
let body = if state.scanned {
catalog.text(Key::SettingsJavaEmptyBody)
} else {
catalog.text(Key::SettingsJavaNeverScannedBody)
};
return text_role_owned(
ui,
TypeRole::BodySmall,
false,
body.to_string(),
ui.color(Role::OnSurfaceVariant),
);
}
for runtime in &state.runtimes {
list = list.push(runtime_row(ui, catalog, state, runtime));
}
list.into()
}
fn runtime_row<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
runtime: &'a RuntimeRow,
) -> Element<'a, Message> {
let is_default = super::state::same_path(&runtime.binary, &state.selected);
// M197:**带方向**的三态。M175 的布尔判定对「1.12.2 + Java 21」会说「低于要求:需要 Java 8
// 及以上」——21 当然 ≥ 8,那句话自相矛盾;现在两个方向各说各的。
let (verdict, verdict_color) = match state.fit(runtime.major) {
Some(super::state::Fit::Ok) => (
catalog.text(Key::SettingsJavaFits).to_string(),
ui.color(Role::Primary),
),
Some(super::state::Fit::TooOld { required, .. }) => (
catalog.render(
Key::SettingsJavaTooOld,
&[("minimum", required.to_string().as_str())],
),
ui.color(Role::Error),
),
Some(super::state::Fit::TooNew { maximum, .. }) => (
catalog.render(
Key::SettingsJavaTooNew,
&[("maximum", maximum.to_string().as_str())],
),
ui.color(Role::Error),
),
None => (String::new(), ui.color(Role::OnSurfaceVariant)),
};
let action = if is_default {
chip(
ui,
// 已选中那颗是**纯展示**的 badge(没有消息,不可交互):r2 复审要求清掉
// 「禁用态 chip 上还挂着一条真消息」的残留。
Chip::badge(catalog.text(Key::SettingsJavaDefault)),
)
} else {
chip(
ui,
Chip::filter(
catalog.text(Key::SettingsJavaSetDefault),
false,
msg(JavaMessage::SelectRuntime(runtime.binary.clone())),
),
)
};
let mut block = column![row![
container(
column![
text_role_owned(
ui,
TypeRole::BodyMedium,
is_default,
runtime.display_home(),
ui.color(Role::OnSurface),
),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
format!(
"{} · {}",
runtime.summary(),
catalog.text(runtime.source_key())
),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
)
.width(Length::Fill),
action,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center)]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !verdict.is_empty() {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
verdict,
verdict_color,
));
}
block.into()
}