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}` 各自成模块。
This commit is contained in:
AskaEth committed 2026-11-08 20:49:56 +08:00
1 parent 329c529bea
commit ce463cc9b5
33 files changed
+3839 -322

No files matched your search

@@ -0,0 +1,329 @@
//! 三个设置键的**编辑区**(M197):`java.installRoot` / `java.vendorPriority` / `java.auto_install`。
//!
//! 每一段都是同一种形状:**能改的控件 + 现在生效什么 + 这个生效值从哪一层来**。第三件事是任务书
//! 点名的(「两者都要能解释『生效值现在是什么、从哪一层来』」),所以三段的最后一行永远不是空的:
//!
//! | 段 | 控件 | 生效值从哪来 |
//! |---|---|---|
//! | 安装目录 | 文本框(可以留空) | `java.installRoot` 或 `core.data_mode` |
//! | 发行版顺序 | 4 行 + 上移 / 下移 + 恢复默认 | `java.vendorPriority` 或地区默认序 |
//! | 缺 JDK 时自动准备 | 开 / 关两片 | 设置本身(出厂开) |
//!
//! 文案一律走 [`Catalog`](本组要 `Lang`,走 `settings/mod.rs` 的按下标分发);渲染器自己不判断
//! 层级,`RootLayer` / `OrderLayer` 是 app 层算好的结论。
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};
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame::{divider, text_role_owned};
use crate::pages::Message;
use crate::theme::Ui;
use super::prefs::{Effective, OrderLayer, RootLayer};
use super::state::State;
use super::view::msg;
use super::Message as JavaMessage;
/// 三段 + 保存按钮(整块的标题由设置页的外壳给,这里不再画)。
pub(super) fn section<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let mut block = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
block = block.push(install_root(ui, catalog, state));
block = block.push(divider(ui));
block = block.push(vendor_order(ui, catalog, state));
block = block.push(divider(ui));
block = block.push(auto_install(ui, catalog, state));
block = block.push(actions(ui, catalog, state));
block.into()
}
/// `java.installRoot`:文本框 + 生效落点 + 来源层。
fn install_root<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let prefs = &state.prefs;
let field = Field::new(&prefs.install_root)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsJavaInstallRoot))
.placeholder(catalog.text(Key::SettingsJavaInstallRootPlaceholder))
.supporting(catalog.text(Key::SettingsJavaInstallRootHint));
let field = if prefs.validate().is_err() {
field.error()
} else {
field
};
let effective = effective_line(ui, catalog, &state.effective);
column![
text_field(ui, field, |value| msg(JavaMessage::EditInstallRoot(value))),
effective,
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 「生效:模式 · 路径」+「从哪一层来」(解析不出标准目录时说清下一步)。
fn effective_line<'a>(
ui: &'a Ui,
catalog: Catalog,
effective: &'a Effective,
) -> Element<'a, Message> {
if effective.root_unresolved {
return text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog
.text(Key::SettingsJavaInstallRootUnresolved)
.to_string(),
ui.color(Role::Error),
);
}
let text = catalog.render(
Key::SettingsJavaInstallRootEffective,
&[
("mode", effective.root_mode),
("path", effective.root.as_str()),
],
);
let layer = match effective.root_layer {
RootLayer::Setting => catalog
.text(Key::SettingsJavaInstallRootLayerSetting)
.to_string(),
RootLayer::DataMode => catalog.render(
Key::SettingsJavaInstallRootLayerDataMode,
&[("mode", effective.root_mode)],
),
};
column![
text_role_owned(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(Role::OnSurface),
),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
layer,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// `java.vendorPriority`:4 行顺序 + 上移 / 下移 + 恢复地区默认序。
fn vendor_order<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let prefs = &state.prefs;
let order = prefs.order(state.effective.domestic);
let mut list = column![text_role_owned(
ui,
TypeRole::BodyMedium,
true,
catalog.text(Key::SettingsJavaVendorPriority).to_string(),
ui.color(Role::OnSurface),
),]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
for (index, vendor) in order.iter().enumerate() {
list = list.push(vendor_row(ui, catalog, index, *vendor));
}
let joined = state.effective.order.join(" → ");
let layer = match state.effective.order_layer {
OrderLayer::Setting => catalog
.text(Key::SettingsJavaVendorOrderLayerSetting)
.to_string(),
OrderLayer::Region => catalog.render(
Key::SettingsJavaVendorOrderLayerRegion,
&[(
"region",
catalog
.text(if state.effective.domestic {
Key::SettingsJavaRegionDomestic
} else {
Key::SettingsJavaRegionGlobal
})
.to_string()
.as_str(),
)],
),
};
list = list.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(
Key::SettingsJavaVendorOrderEffective,
&[("order", joined.as_str())],
),
ui.color(Role::OnSurface),
));
list = list.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
layer,
ui.color(Role::OnSurfaceVariant),
));
// 「恢复地区默认序」只在真的排过顺序时才有意义(已经跟地区走时它是个空操作)。
if !prefs.priority.is_empty() {
list = list.push(buttons::outlined(
ui,
Button::new(
catalog.text(Key::SettingsJavaVendorReset),
msg(JavaMessage::ResetPriority),
),
));
}
list.into()
}
/// 一家发行版一行:名 + 它发哪些大版本 + 上移 / 下移。
fn vendor_row<'a>(
ui: &'a Ui,
catalog: Catalog,
index: usize,
vendor: unlucky_core::game::java::JdkVendor,
) -> Element<'a, Message> {
let majors = vendor
.hint_majors()
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join("/");
let supporting = catalog.render(
Key::SettingsJavaVendorSupports,
&[("majors", majors.as_str())],
);
// 第一条的「上移」与最后一条的「下移」都是空操作:照旧可点,点了不改顺序也不标脏
// (比灰掉更省心——灰掉的 chip 在 424 宽的列里同样占位,用户还得去猜为什么灰)。
let up = chip(
ui,
Chip::filter(
catalog.text(Key::SettingsJavaVendorUp),
false,
msg(JavaMessage::MoveVendor { index, up: true }),
),
);
let down = chip(
ui,
Chip::filter(
catalog.text(Key::SettingsJavaVendorDown),
false,
msg(JavaMessage::MoveVendor { index, up: false }),
),
);
row![
container(
column![
text_role_owned(
ui,
TypeRole::BodyMedium,
index == 0,
format!("{}. {}", index + 1, vendor.display()),
ui.color(Role::OnSurface),
),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
supporting,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
)
.width(Length::Fill),
up,
down,
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// `java.auto_install`:开 / 关两片 + 「它到底做什么」那句。
fn auto_install<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let on = state.prefs.auto_install;
let chips = row![
chip(
ui,
Chip::filter(
catalog.text(Key::SettingsJavaAutoInstallOn),
on,
msg(JavaMessage::ToggleAutoInstall(true)),
),
),
chip(
ui,
Chip::filter(
catalog.text(Key::SettingsJavaAutoInstallOff),
!on,
msg(JavaMessage::ToggleAutoInstall(false)),
),
),
]
.spacing(ui.space(Space::X1));
column![
text_role_owned(
ui,
TypeRole::BodyMedium,
true,
catalog.text(Key::SettingsJavaAutoInstall).to_string(),
ui.color(Role::OnSurface),
),
chips,
text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsJavaAutoInstallHint).to_string(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 保存按钮 + 「有未保存改动」那行。
fn actions<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let label = catalog.text(Key::SettingsJavaPrefsSave);
let save = if state.prefs.dirty() && state.storage {
buttons::filled(ui, Button::new(label, msg(JavaMessage::SavePrefs)))
} else {
buttons::filled(ui, Button::disabled(label))
};
let hint = if state.prefs.dirty() {
catalog.text(Key::SettingsJavaPrefsUnsaved).to_string()
} else {
String::new()
};
column![
save,
text_role_owned(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}