Merge branch 'feat/m99-settingsframe-split-settings-page-na'

This commit is contained in:
AskaEth committed 2026-11-03 13:19:20 +08:00
commit 2ad808d55c
29 files changed
+1173 -541

No files matched your search

+8 -52
View File
@@ -6,11 +6,12 @@
//! |---|---|---|
//! | M84 | **布局系统**(档位/版面/列宽)+ 四页占位视觉 | 下面的版面阈值表 |
//! | **M90(本文件当前状态)** | ①视图模型 [`model`] ②导航项接上 `presenter::router::Route` ③**实例页整页接真组件 + 真数据** | 「接真组件后版面约束一条没破」(见 `pages/tests.rs` 的数值断言) |
//! | M99 `SETTINGSFRAME` | **纯搬移**:导航表搬到 [`nav`],设置页拆成目录(`pages/settings/**`),i18n 目录按域拆开 | 截图逐字节相同 + `catalog` 金表(`key -> 译文` 全量不变) |
//!
//! **导航轨归属(M90 r3 订正,复审 P2-1)**:产品路径(`app/view.rs`)走
//! `shell::view_with_content`,导航轨由 **`shell/nav.rs` 的手搓实现**画——它与 M78 组件几何同值
//! (宽 88、药丸 56×32),复审逐像素量过「r1 产品图(真组件 rail)vs r2 产品图(shell rail)
//! 在 y≥48 区域差异为 0」;组件版的 [`rail`] 现在只服务 `pages::app`(`pages_demo` 出口图、
//! 在 y≥48 区域差异为 0」;组件版的 `nav::rail` 现在只服务 `pages::app`(`pages_demo` 出口图、
//! harness)与单测。为什么不是「把 rail 挪进内容槽位」:那需要把 `shell` 的消息类型泛型化/映射,
//! 成本远大于收益(见偏离表 #10)。
//!
@@ -41,8 +42,9 @@
//! | [`layout`] | **布局系统**:档位 / 版面 / 列宽 / 阈值,纯函数 + 单测(无 iced 控件树) |
//! | [`model`] | **视图模型**:presenter 快照 → 页面要画的东西(页面层不认识 presenter 类型) |
//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线 |
//! | [`nav`] | 导航轨的 `NAV` 表 + 部件版 `rail()`(harness / 单测;产品走 `shell/nav.rs`) |
//! | `instances` | 实例页(list-detail)——**M90 唯一打通到真组件 + 真数据真命令的页** |
//! | `settings` | 设置页(supporting-pane,占位视觉) |
//! | `settings` | 设置页(supporting-pane,占位视觉):分组注册表 + 每个分组一个文件 |
//! | `download` / `logs` | 下载 / 日志页(单列,占位视觉) |
//! | [`harness`] | 截图 harness(`examples/pages_demo.rs` 的入口,**不是** `cli.rs` 的一部分) |
//!
@@ -76,9 +78,9 @@
//!
//! 修法:`shell::view_with_content(ui, nav, title, content, forced)` 把外壳(标题栏 + 三条热区 +
//! 导航轨)与内容区拆开,产品页把 [`page`] 的树塞进内容槽位;`shell::view`(M68 骨架卡)保留给
//! `Route::Agent` 回落 —— 删了会让私有模块里的 `view` / `nav::rail` 变死代码,`-D warnings` 会红。
//! `Route::Agent` 回落 —— 删了会让私有模块里的 `view` / `shell::nav::rail` 变死代码,`-D warnings` 会红。
//!
//! [`app`](本文件)现在**只服务 harness**(`pages_demo` example 与单测):它的标题栏仍是留白带,
//! `pages::app`(本文件)现在**只服务 harness**(`pages_demo` example 与单测):它的标题栏仍是留白带,
//! 因为 `shell` 的回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。
//!
//! ## 截图通道
@@ -101,6 +103,7 @@ pub mod model;
mod download;
mod instances;
mod logs;
mod nav;
mod settings;
#[cfg(test)]
@@ -114,11 +117,8 @@ use iced::widget::{column, container, row, space};
use iced::{Element, Length};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_presenter::router::{NavSection, Route};
use crate::app::components::navigation::drawer::{self, Navigation};
use crate::app::components::navigation::navigation;
use crate::theme::Ui;
use frame::filled;
@@ -266,33 +266,6 @@ impl Screen {
}
}
/// 导航轨的固定项:图标 + 标签 + 目标路由(顺序 = 自上而下)。
///
/// 顺序与 `presenter::router::NavSection` 的声明顺序一致,改这里等于改导航栏。
const NAV: [(Option<NavSection>, IconKind, &str, Route); 5] = [
(
Some(NavSection::Instances),
IconKind::Instances,
"Instances",
Route::Instances,
),
(
Some(NavSection::Download),
IconKind::Download,
"Download",
Route::Download,
),
(Some(NavSection::Logs), IconKind::Logs, "Logs", Route::Logs),
(
Some(NavSection::Settings),
IconKind::Settings,
"Settings",
Route::Settings,
),
// AI 独立页没有导航高亮项(`Route::nav()` 对它返回 `None`)
(None, IconKind::Ai, "Assistant", Route::Agent),
];
/// **单页内容**(不含外壳):交给 `shell::view_with_content` 塞进真标题栏与导航轨之间。
///
/// 产品路径(`app/view.rs`)走 [`page_with_appearance`]:外壳由 `shell` 画(拖动/缩放/三个
@@ -341,7 +314,7 @@ fn render<'a>(
pub fn app<'a>(ui: &'a Ui, model: &'a Model) -> Element<'a, Message> {
let body = page(ui, model);
let shell = row![rail(ui, model.active), body]
let shell = row![nav::rail(ui, model.active), body]
.width(Length::Fill)
.height(Length::Fill);
@@ -370,23 +343,6 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model {
model
}
/// 导航轨:**真组件**(`components::navigation` 的 rail 形态,宽 = 令牌 `nav_rail_width` = 88)。
///
/// M84 这里是一条留白带;M90 换成 M78 的 `NavigationRail`,于是「导航项 → 路由」是真的可点。
fn rail<'a>(ui: &'a Ui, active: Option<NavSection>) -> Element<'a, Message> {
let mut group = drawer::NavGroup::new();
for (section, icon, label, route) in NAV {
let mut item = drawer::NavItem::new(label, icon, Message::Navigate(route));
if section.is_some() && active == section {
item = item.selected();
}
group = group.item(item);
}
navigation(ui, Navigation::rail().group(group))
}
/// 标题栏留白带(高 = `LayoutMetrics::titlebar_height`)——**只给 [`app`] 的 harness 路径用**。
///
/// 产品路径的标题栏是 `shell::view_with_content` 画的那个真的(三个 caption 键 + 拖动热区),
+71
View File
@@ -0,0 +1,71 @@
//! 导航轨的**部件版**实现:`NAV` 表 + [`rail`](宽 = 令牌 `nav_rail_width` = 88)。
//!
//! ## 两套 rail 的关系(M90 r3 订正,复审 P2-1;M99 把这张表从 `pages/mod.rs` 搬到这里)
//!
//! 产品路径(`app/view.rs`)走 `shell::view_with_content`,导航轨由 **`shell/nav.rs` 的手搓实现**画
//! ——它与 M78 组件几何同值(宽 88、药丸 56×32),复审逐像素量过「r1 产品图(真组件 rail)vs r2
//! 产品图(shell rail)在 y≥48 区域差异为 0」。组件版的 [`rail`] 只服务 `pages::app`
//! (`pages_demo` 出口图、harness)与单测。
//!
//! 两边的守门是分开的:`shell/nav.rs` 自带 indicator 56×32 的单测,`pages/tests.rs` 守的是下面
//! 这张表(表长 5 / 每一项都指向能高亮它的路由 / 第 5 项没有 section)。**将来哪边先漂没人拦**
//! ——统一要泛型化 shell 的消息类型,成本大于收益;在那之前任何改 rail 几何的改动都要两处一起改
//! 并各自跑守门(`pages/mod.rs` 偏离登记 #10)。
use iced::Element;
use unlucky_design::icons::IconKind;
use unlucky_presenter::router::{NavSection, Route};
use crate::app::components::navigation::drawer::{self, Navigation};
use crate::app::components::navigation::navigation;
use crate::theme::Ui;
use super::Message;
/// 导航轨的固定项:图标 + 标签 + 目标路由(顺序 = 自上而下)。
///
/// 顺序与 `presenter::router::NavSection` 的声明顺序一致,改这里等于改导航栏。
///
/// 可见性是 `pub(crate)`:`pages/tests.rs` 的三条断言读它(M99 从 `pages/mod.rs` 搬出来时,
/// 它从「模块私有」变成「crate 内可见」,断言一条没动、强度也没降)。
pub(crate) const NAV: [(Option<NavSection>, IconKind, &str, Route); 5] = [
(
Some(NavSection::Instances),
IconKind::Instances,
"Instances",
Route::Instances,
),
(
Some(NavSection::Download),
IconKind::Download,
"Download",
Route::Download,
),
(Some(NavSection::Logs), IconKind::Logs, "Logs", Route::Logs),
(
Some(NavSection::Settings),
IconKind::Settings,
"Settings",
Route::Settings,
),
// AI 独立页没有导航高亮项(`Route::nav()` 对它返回 `None`)
(None, IconKind::Ai, "Assistant", Route::Agent),
];
/// 导航轨:**真组件**(`components::navigation` 的 rail 形态,宽 = 令牌 `nav_rail_width` = 88)。
///
/// M84 这里是一条留白带;M90 换成 M78 的 `NavigationRail`,于是「导航项 → 路由」是真的可点。
pub(super) fn rail<'a>(ui: &'a Ui, active: Option<NavSection>) -> Element<'a, Message> {
let mut group = drawer::NavGroup::new();
for (section, icon, label, route) in NAV {
let mut item = drawer::NavItem::new(label, icon, Message::Navigate(route));
if section.is_some() && active == section {
item = item.selected();
}
group = group.item(item);
}
navigation(ui, Navigation::rail().group(group))
}
-367
View File
@@ -1,367 +0,0 @@
//! 设置页:**supporting-pane** 的用例(左侧分组导航 240 + 右侧表单列 ≤424)。
//!
//! ## 为什么是 supporting-pane 而不是 list-detail
//!
//! 分组导航是**辅助**(它不承载数据,只切换右侧表单的内容),右侧表单才是阅读对象;
//! 官方把这种「主列 + 窄侧列」叫 supporting pane。list-detail 的前提是左列本身是一份可浏览的数据集
//! (实例列表就是),设置页不满足。
//!
//! ## 侧列与 rail 的关系(D33)
//!
//! 宽度 240 直接取 `LayoutMetrics::nav_drawer_width`(D33:同一容器 rail 88 ⇄ drawer 240 的展开态),
//! 但**不复制 rail 的实现**:这里没有 hover 药丸、没有二级标签、没有 `Message::Select`。
//! 将来真正的 `NavigationDrawer` 由 M78 提供,本页换掉 `group_nav()` 即可。
//!
//! ## 退化(窗口 <720 或 compact 档)
//!
//! 侧列消失,分组导航退化成「按组堆叠」:同一列里每组一张卡 + 一段 12pt 组名。整页仍居中、正文列仍 ≤424。
use iced::widget::{column, container, mouse_area, row, scrollable};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::appearance::{self, Change};
use super::frame::{self, Header};
use super::layout::{FrameMetrics, PageLayout};
use super::{Action, Message};
/// 一个设置分组:名字、图标、以及若干「标签 = 值」行。
struct Group {
name: &'static str,
icon: IconKind,
rows: &'static [(&'static str, &'static str)],
}
/// 演示分组(骨架期:值只是文本,真控件等 M76 的输入家族)。
const GROUPS: [Group; 4] = [
Group {
name: "General",
icon: IconKind::Settings,
rows: &[
("Language", "English (System)"),
("Theme", "Follow system"),
("Startup page", "Instances"),
("Close button", "Exit to tray"),
],
},
Group {
name: "Appearance",
icon: IconKind::Sidebar,
rows: &[
("Density", "Comfortable"),
("Corner style", "Rounded"),
("Motion", "Full"),
("Text scale", "100%"),
],
},
Group {
// 描边字形:令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架(视觉复审 P2-3);
// Java 分组讲的是运行时安装位置,用目录图标语义也对得上。
name: "Java",
icon: IconKind::Folder,
rows: &[
("Runtime", "Temurin 21.0.4"),
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
],
},
Group {
name: "Downloads",
icon: IconKind::Download,
rows: &[
("Preferred source", "Automatic (region)"),
("Concurrency", "8 threads"),
("Verify checksums", "On"),
],
},
];
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
const SELECTED: usize = 0;
/// 空态位高度:给图标块 96 + 标题 + 副文案 + 按钮留够空间(`[估]`,按 12b §15 的元素最小高度累加)。
const EMPTY_SLOT_HEIGHT: f32 = 320.0;
/// 整页。
///
/// `appearance` 是 M93 接进来的外观运行期状态(`None` = harness / 演示路径,设置页仍画占位行)。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
appearance: Option<&'a appearance::State>,
) -> Element<'a, Message> {
let header = Header::new("Settings")
.subtitle("Launcher behaviour, Java runtimes and download sources")
.action(frame::text_action(
ui,
"Reset section",
Action::ResetSection,
))
.action(frame::text_action(ui, "Save", Action::SaveSettings));
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
let selected = appearance.map_or(SELECTED, |state| state.selected_group);
let body = match metrics.layout {
PageLayout::SupportingPane => frame::panes(
metrics,
group_nav(ui, selected),
frame::left_aligned(metrics.column, form(ui, Some(selected), appearance)),
),
_ => frame::single_column(metrics, form(ui, None, appearance)),
};
frame::page(ui, metrics, header, body)
}
// --------------------------------------------------------------- 侧列
fn group_nav<'a>(ui: &'a Ui, selected: usize) -> Element<'a, Message> {
let items = column(
GROUPS
.iter()
.enumerate()
.map(|(index, group)| group_item(ui, group, index, index == selected))
.collect::<Vec<_>>(),
)
.spacing(ui.space(Space::X1))
.padding(Padding::from([ui.space(Space::X2), ui.space(Space::X2)]))
.width(Length::Fill);
container(items)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}
fn group_item<'a>(
ui: &'a Ui,
group: &'a Group,
index: usize,
selected: bool,
) -> Element<'a, Message> {
let spec = Component::NavigationItem.spec();
let height = ui.metrics().row_height;
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
let inner = row![
frame::glyph_box(group.icon, content_color, frame::ICON_PX),
frame::text_role(ui, spec.text, false, group.name, content_color),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center);
let pill = container(inner)
.width(Length::Fill)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
..container::Style::default()
});
// 点选切分组:走页面自己的 `Message::Appearance(SelectGroup)`(只改视图状态,不落盘)。
mouse_area(pill)
.on_press(Message::Appearance(Change::SelectGroup(index)))
.into()
}
// --------------------------------------------------------------- 表单列
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
fn form<'a>(
ui: &'a Ui,
only: Option<usize>,
appearance: Option<&'a appearance::State>,
) -> Element<'a, Message> {
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
for (index, group) in GROUPS.iter().enumerate() {
if only.is_some_and(|selected| selected != index) {
continue;
}
// 「外观」分组:有真实外观状态时画真控件(M93),否则退回演示行(harness)。
let card = match appearance {
Some(state) if index == appearance::APPEARANCE_GROUP => appearance::section(ui, state),
_ => group_card(ui, group),
};
stack = stack.push(
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
group.name,
ui.color(Role::OnSurfaceVariant),
),
card,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill),
);
}
stack = stack.push(overrides_empty(ui));
// 「外观」分组有 7 个控件 + 最多 3 行诊断:1180×760 下会超出可视区,所以表单列自己滚动
// (页头不跟着滚,与实例页列表列同一套口径)。
scrollable(stack)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(frame::scroll_style(ui))
.into()
}
/// 一组设置 = 一张卡,行间 1px 分隔线(M3 设置列表的常规做法)。
fn group_card<'a>(ui: &'a Ui, group: &'a Group) -> Element<'a, Message> {
let mut rows = column![].spacing(0.0).width(Length::Fill);
for (index, (label, value)) in group.rows.iter().enumerate() {
if index > 0 {
rows = rows.push(frame::divider(ui));
}
rows = rows.push(setting_row(ui, label, value));
}
frame::card(ui, rows)
}
fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Message> {
let height = ui.metrics().control_height + Space::X2.dp();
row![
container(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
label,
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
value,
ui.color(Role::OnSurfaceVariant),
),
]
.height(height)
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 空态位:没有逐实例覆盖时的样子(12b §15 的口径)。
fn overrides_empty<'a>(ui: &'a Ui) -> Element<'a, Message> {
container(frame::empty_state(
ui,
IconKind::Instances,
"No per-instance overrides",
"Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest \
of the launcher keeps the defaults above.",
vec![frame::text_action(ui, "Add override", Action::AddOverride)],
))
.width(Length::Fill)
.height(Length::Fixed(EMPTY_SLOT_HEIGHT))
.into()
}
#[cfg(test)]
mod tests {
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
use super::*;
#[test]
fn side_nav_reuses_the_drawer_width() {
// D33:分组导航走 drawer 240(同一容器 rail 88 ⇄ drawer 240),不是 rail 的 88
assert_eq!(SIDE_NAV_WIDTH, 240.0);
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
assert_eq!(metrics.secondary, Some(240.0));
assert_ne!(metrics.secondary, Some(layout::rail_width()));
}
#[test]
fn form_column_never_exceeds_the_measure_cap() {
for width in [720.0, 840.0, 1180.0, 1920.0] {
let metrics = frame_metrics(PaneKit::Supporting, width);
assert!(
metrics.column <= measure_cap_px() + f32::EPSILON,
"{width} 宽下表单列 {:?} 超过行宽上限",
metrics.column
);
assert!(metrics.column >= layout::measure_floor_px());
}
}
#[test]
fn every_group_has_a_name_icon_and_rows() {
assert!(GROUPS.len() >= 3);
assert!(SELECTED < GROUPS.len());
for group in &GROUPS {
assert!(!group.name.is_empty());
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name);
assert!(!group.rows.is_empty(), "{} 没有设置行", group.name);
for (label, value) in group.rows {
assert!(!label.is_empty() && !value.is_empty());
}
}
}
#[test]
fn narrow_window_stacks_the_groups_instead_of_the_side_nav() {
let narrow = frame_metrics(PaneKit::Supporting, 700.0);
assert_eq!(narrow.layout, PageLayout::SingleColumn);
assert!(narrow.secondary.is_none(), "窄窗不许留侧列");
}
/// M93:设置页「外观」分组的下标与 `pages::appearance` 声明的常量必须一致。
///
/// 抓得住:谁把 GROUPS 的顺序调了却忘了改 `APPEARANCE_GROUP`(那样真控件会画到别的分组上,
/// 而支持列显示的分组名也对不上)。抓不住:控件本身画得对不对(那由截图与 app 侧测试管)。
#[test]
fn the_appearance_group_is_where_the_module_says_it_is() {
assert_eq!(
GROUPS[appearance::APPEARANCE_GROUP].name,
appearance::GROUP_LABEL
);
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}
}
+13
View File
@@ -0,0 +1,13 @@
//! 设置页「AI / About」分组:**预留**(页面上还没有这个分组,左列 4 项里没有它)。
//!
//! 为什么先留一个空文件:设置页正文接下来由四条线并行接(java / download / language+appearance /
//! ai+about)。文件位置先占好,四条线就不会在同一天抢同一个新文件名;本文件现在是**只有文档**的
//! 空模块(不产生任何代码,`-D warnings` 不会红)。
//!
//! ## 接线步骤(ai+about 线照做)
//!
//! 1. 在本文件写 `pub(super) const GROUP: Group = Group { … }`,形状抄 `java.rs`;
//! 2. 到 `settings/mod.rs` 的 `GROUPS` 里加一行 `ai::GROUP,`,并把数组长度 `4` 改成 `5`;
//! 3. 文案 key 写进 `crates/i18n/src/catalog/settings/ai.rs`(那边已经接进装配链,同样只改一个文件)。
//!
//! 界面上「多了一组」这件事本身是行为变化,所以**只在真有那一组内容时才做**,不要为占位先加。
@@ -0,0 +1,27 @@
//! 设置页「Appearance」分组:演示行 + **真控件的接线**(M93 / APPEARWIRE)。
//!
//! 这一组是设置页唯一接了真控件的:`body = Some(crate::pages::appearance::section)`。
//! `settings/mod.rs` 只按 `group.body` 分发,**不认识「Appearance」这个名字**;分组在左列的下标仍由
//! `crate::pages::appearance::APPEARANCE_GROUP` 声明,`settings/tests.rs` 拿它和本文件比对(顺序被
//! 调换会红)。
//!
//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section.rs`(本文件只负责
//! 把它挂进注册表),所以改控件时不用碰设置页目录。
use unlucky_design::icons::IconKind;
use super::Group;
/// Appearance 分组。`body` 指着 `pages/appearance/section.rs` 的真控件;没有外观运行期状态时
/// (harness / `pages_demo`)退回下面的演示行。
pub(super) const GROUP: Group = Group {
name: "Appearance",
icon: IconKind::Sidebar,
rows: &[
("Density", "Comfortable"),
("Corner style", "Rounded"),
("Motion", "Full"),
("Text scale", "100%"),
],
body: Some(crate::pages::appearance::section),
};
@@ -0,0 +1,22 @@
//! 设置页「Downloads」分组:首选源 / 并发数 / 校验(演示行)。
//!
//! **这一组归 download 线**:接真控件时在本文件把 `body` 换成 `Some(...)`(渲染器也写在本文件),
//! `settings/mod.rs` 的注册表不用动。
//!
//! 注意与下载页(`pages/download*`)的区别:那是「装哪个版本」,这里是「怎么下」的默认值。
use unlucky_design::icons::IconKind;
use super::Group;
/// Downloads 分组。
pub(super) const GROUP: Group = Group {
name: "Downloads",
icon: IconKind::Download,
rows: &[
("Preferred source", "Automatic (region)"),
("Concurrency", "8 threads"),
("Verify checksums", "On"),
],
body: None,
};
+195
View File
@@ -0,0 +1,195 @@
//! 设置页骨架:页头、左列分组导航、表单列的滚动区、演示卡片、空态位。
//!
//! ## 为什么这里还再导出一批 `pages::frame` 的名字
//!
//! M99 拆目录时立的硬要求是**纯搬移**:调用点一字不改。设置页原来的代码从 `super::frame` 取词
//! (`frame::text_role` / `frame::card` / `frame::ICON_PX` …);拆成目录后 `frame` 这个名字落到了
//! 本文件上,于是把用到的那几件**再导出**一次,`mod.rs` / `tests.rs` 的调用点就能原样保留。
//! 这不是新 API,只是一条转发(新增控件仍归 [`crate::pages::frame`])。
pub(super) use super::super::frame::{
card, divider, empty_state, glyph_box, left_aligned, page, panes, scroll_style, single_column,
text_action, text_role, Header, ICON_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH,
};
use iced::widget::{column, container, mouse_area, row, scrollable};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::pages::appearance::Change;
use crate::pages::{Action, Message};
use crate::theme::Ui;
use super::Group;
/// 空态位高度:给图标块 96 + 标题 + 副文案 + 按钮留够空间(`[估]`,按 12b §15 的元素最小高度累加)。
const EMPTY_SLOT_HEIGHT: f32 = 320.0;
/// 页头:标题 + 副标题 + 两个占位动作。
pub(super) fn header(ui: &Ui) -> Header<'_> {
Header::new("Settings")
.subtitle("Launcher behaviour, Java runtimes and download sources")
.action(text_action(ui, "Reset section", Action::ResetSection))
.action(text_action(ui, "Save", Action::SaveSettings))
}
// --------------------------------------------------------------- 侧列
/// 左列分组导航(supporting-pane 的辅助列,宽 = 令牌 `nav_drawer_width` = 240)。
pub(super) fn group_nav<'a>(ui: &'a Ui, selected: usize) -> Element<'a, Message> {
let items = column(
super::GROUPS
.iter()
.enumerate()
.map(|(index, group)| group_item(ui, group, index, index == selected))
.collect::<Vec<_>>(),
)
.spacing(ui.space(Space::X1))
.padding(Padding::from([ui.space(Space::X2), ui.space(Space::X2)]))
.width(Length::Fill);
container(items)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}
fn group_item<'a>(
ui: &'a Ui,
group: &'a Group,
index: usize,
selected: bool,
) -> Element<'a, Message> {
let spec = Component::NavigationItem.spec();
let height = ui.metrics().row_height;
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
let inner = row![
glyph_box(group.icon, content_color, ICON_PX),
text_role(ui, spec.text, false, group.name, content_color),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_y(Alignment::Center);
let pill = container(inner)
.width(Length::Fill)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
..container::Style::default()
});
// 点选切分组:走页面自己的 `Message::Appearance(SelectGroup)`(只改视图状态,不落盘)。
mouse_area(pill)
.on_press(Message::Appearance(Change::SelectGroup(index)))
.into()
}
// --------------------------------------------------------------- 表单列
/// 表单列的滚动区:只有正文滚,页头不动(与实例页列表列同一套口径)。
///
/// 「外观」分组有 7 个控件 + 最多 3 行诊断,1180×760 下会超出可视区,所以这一层是必需的。
pub(super) fn scroll<'a>(
ui: &'a Ui,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
scrollable(content)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(SCROLLBAR_WIDTH)
.scroller_width(SCROLLBAR_WIDTH)
.margin(SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(scroll_style(ui))
.into()
}
/// 演示行构成的一组设置 = 一张卡,行间 1px 分隔线(M3 设置列表的常规做法)。
///
/// 已经接了真控件的分组(`GROUP.body` 是 `Some`)不走这里。
pub(super) fn demo_card<'a>(
ui: &'a Ui,
rows: &'a [(&'static str, &'static str)],
) -> Element<'a, Message> {
let mut stacked = column![].spacing(0.0).width(Length::Fill);
for (index, (label, value)) in rows.iter().enumerate() {
if index > 0 {
stacked = stacked.push(divider(ui));
}
stacked = stacked.push(setting_row(ui, label, value));
}
card(ui, stacked)
}
fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Message> {
let height = ui.metrics().control_height + Space::X2.dp();
row![
container(text_role(
ui,
TypeRole::BodyMedium,
false,
label,
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.align_y(Alignment::Center),
text_role(
ui,
TypeRole::BodySmall,
false,
value,
ui.color(Role::OnSurfaceVariant),
),
]
.height(height)
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 空态位:没有逐实例覆盖时的样子(12b §15 的口径)。
pub(super) fn overrides_empty<'a>(ui: &'a Ui) -> Element<'a, Message> {
container(empty_state(
ui,
IconKind::Instances,
"No per-instance overrides",
"Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest \
of the launcher keeps the defaults above.",
vec![text_action(ui, "Add override", Action::AddOverride)],
))
.width(Length::Fill)
.height(Length::Fixed(EMPTY_SLOT_HEIGHT))
.into()
}
@@ -0,0 +1,24 @@
//! 设置页「General」分组:语言 / 主题 / 启动页 / 关闭按钮(演示行)。
//!
//! **这一组归 language+appearance 线**:语言那一行将来换真下拉(`pages::Message` 侧还需一条
//! 「设置项改动」的消息,见 `pages/mod.rs` 的 `Message`),改这里 + 那一处消息即可,
//! `settings/mod.rs` 的注册表不用动。
//!
//! 分组元数据只有一份(这里的 [`GROUP`]):`settings/mod.rs` 的 `GROUPS` 直接引用它。
use unlucky_design::icons::IconKind;
use super::Group;
/// General 分组(演示行仍是 M84 的文本行;真控件接进来时把 `body` 换成 `Some(...)`)。
pub(super) const GROUP: Group = Group {
name: "General",
icon: IconKind::Settings,
rows: &[
("Language", "English (System)"),
("Theme", "Follow system"),
("Startup page", "Instances"),
("Close button", "Exit to tray"),
],
body: None,
};
@@ -0,0 +1,23 @@
//! 设置页「Java」分组:运行时 / 默认内存 / JVM 参数(演示行)。
//!
//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(...)`(渲染器也写在本文件),
//! 再把 `rows` 换成真数据源给的文本;`settings/mod.rs` 的注册表不用动。
//!
//! 图标说明(视觉复审 P2-3):令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架;
//! Java 分组讲的是运行时安装位置,用目录图标语义也对得上。
use unlucky_design::icons::IconKind;
use super::Group;
/// Java 分组。
pub(super) const GROUP: Group = Group {
name: "Java",
icon: IconKind::Folder,
rows: &[
("Runtime", "Temurin 21.0.4"),
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
],
body: None,
};
+159
View File
@@ -0,0 +1,159 @@
//! 设置页:**supporting-pane** 的用例(左侧分组导航 240 + 右侧表单列 ≤424)。
//!
//! ## 为什么是 supporting-pane 而不是 list-detail
//!
//! 分组导航是**辅助**(它不承载数据,只切换右侧表单的内容),右侧表单才是阅读对象;
//! 官方把这种「主列 + 窄侧列」叫 supporting pane。list-detail 的前提是左列本身是一份可浏览的数据集
//! (实例列表就是),设置页不满足。
//!
//! ## 侧列与 rail 的关系(D33)
//!
//! 宽度 240 直接取 `LayoutMetrics::nav_drawer_width`(D33:同一容器 rail 88 ⇄ drawer 240 的展开态),
//! 但**不复制 rail 的实现**:这里没有 hover 药丸、没有二级标签、没有 `Message::Select`。
//! 将来真正的 `NavigationDrawer` 由 M78 提供,本页换掉 [`frame::group_nav`] 即可。
//!
//! ## 退化(窗口 <720 或 compact 档)
//!
//! 侧列消失,分组导航退化成「按组堆叠」:同一列里每组一张卡 + 一段 12pt 组名。整页仍居中、正文列仍 ≤424。
//!
//! ## 文件地图(M99 `SETTINGSFRAME`:**纯搬移**,行为与视觉零变化)
//!
//! 本文件只做两件事:**分组注册表**([`GROUPS`])+ **内容分发**([`form`] 按分组挑渲染器)。
//! 一组的正文、文案、演示行都在它自己的文件里,于是设置页接下来的四条 section 线各占一个文件:
//!
//! | 分组 | 文件 | 谁会动它 |
//! |---|---|---|
//! | General | [`general`] | language+appearance 线(语言下拉 + 主题 / 启动页) |
//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section.rs`) |
//! | Java | [`java`] | java 线 |
//! | Downloads | [`download`] | download 线 |
//! | (页面上还没有这个分组) | [`ai`] | ai+about 线:先建分组再接线 |
//! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) |
//!
//! **加一个分组要动哪里**:① 在它自己的文件里写一个 `pub(super) const GROUP`(名字 / 图标 / 演示行 /
//! 可选的真控件渲染器 `body`);② 本文件的 [`GROUPS`] 加一行、数组长度 +1。**不需要**动 `frame.rs`、
//! 别的分组文件或 `pages/mod.rs`。
mod ai;
mod appearance;
mod download;
mod frame;
mod general;
mod java;
#[cfg(test)]
mod tests;
use iced::widget::column;
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::pages::appearance::State;
use crate::theme::Ui;
use super::layout::{FrameMetrics, PageLayout};
use super::Message;
/// 一个分组正文的**真控件渲染器**:需要外观的运行期状态(现在只有「外观」分组用得上)。
type Body = for<'a> fn(&'a Ui, &'a State) -> Element<'a, Message>;
/// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。
///
/// 除 `name`/`icon`/`rows` 外没有别的字段:**分组自己的状态归分组自己**,这里只装「怎么画」。
struct Group {
/// 左列显示名(也是表单列的分组标题)。
name: &'static str,
/// 左列图标。
icon: IconKind,
/// 演示行(`body` 为 `None` 时画这些;harness 出图也靠它们)。
rows: &'static [(&'static str, &'static str)],
/// 真控件渲染器;`None` = 这一组还没接真控件,画 `rows`。
body: Option<Body>,
}
/// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。
///
/// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。
const GROUPS: [Group; 4] = [
general::GROUP,
appearance::GROUP,
java::GROUP,
download::GROUP,
];
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
const SELECTED: usize = 0;
/// 整页。
///
/// `appearance` 是 M93 接进来的外观运行期状态(`None` = harness / 演示路径,设置页仍画占位行)。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
appearance: Option<&'a State>,
) -> Element<'a, Message> {
// 选中分组:有真实外观状态时看状态(点左列可切),否则退回演示值 0。
let selected = appearance.map_or(SELECTED, |state| state.selected_group);
let body = match metrics.layout {
PageLayout::SupportingPane => frame::panes(
metrics,
frame::group_nav(ui, selected),
frame::left_aligned(metrics.column, form(ui, Some(selected), appearance)),
),
_ => frame::single_column(metrics, form(ui, None, appearance)),
};
frame::page(ui, metrics, frame::header(ui), body)
}
// --------------------------------------------------------------- 内容分发
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
///
/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some` 画真控件(现在只有「外观」),
/// `None` 画演示行([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标。
fn form<'a>(
ui: &'a Ui,
only: Option<usize>,
appearance: Option<&'a State>,
) -> Element<'a, Message> {
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
for (index, group) in GROUPS.iter().enumerate() {
if only.is_some_and(|selected| selected != index) {
continue;
}
// 「外观」分组:有真实外观状态时画真控件(M93),否则退回演示行(harness)。
// 这两条的差别现在由 `GROUP.body` 表达,而不是「下标 == APPEARANCE_GROUP」的硬比较。
let card = match (group.body, appearance) {
(Some(body), Some(state)) => body(ui, state),
_ => frame::demo_card(ui, group.rows),
};
stack = stack.push(
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
group.name,
ui.color(Role::OnSurfaceVariant),
),
card,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill),
);
}
stack = stack.push(frame::overrides_empty(ui));
frame::scroll(ui, stack)
}
@@ -0,0 +1,95 @@
//! 设置页的守门测试(M99 拆目录时**原样搬出来**,用例与断言一字未动;断言强度只增不减)。
//!
//! 站位说明:本文件是 `pages::settings::tests`,所以 `super::super` 就是 `pages`。
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
use super::*;
use crate::pages::appearance::{APPEARANCE_GROUP, GROUP_LABEL};
#[test]
fn side_nav_reuses_the_drawer_width() {
// D33:分组导航走 drawer 240(同一容器 rail 88 ⇄ drawer 240),不是 rail 的 88
assert_eq!(SIDE_NAV_WIDTH, 240.0);
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
assert_eq!(metrics.secondary, Some(240.0));
assert_ne!(metrics.secondary, Some(layout::rail_width()));
}
#[test]
fn form_column_never_exceeds_the_measure_cap() {
for width in [720.0, 840.0, 1180.0, 1920.0] {
let metrics = frame_metrics(PaneKit::Supporting, width);
assert!(
metrics.column <= measure_cap_px() + f32::EPSILON,
"{width} 宽下表单列 {:?} 超过行宽上限",
metrics.column
);
assert!(metrics.column >= layout::measure_floor_px());
}
}
#[test]
fn every_group_has_a_name_icon_and_rows() {
assert!(GROUPS.len() >= 3);
assert!(SELECTED < GROUPS.len());
for group in &GROUPS {
assert!(!group.name.is_empty());
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name);
assert!(!group.rows.is_empty(), "{} 没有设置行", group.name);
for (label, value) in group.rows {
assert!(!label.is_empty() && !value.is_empty());
}
}
}
#[test]
fn narrow_window_stacks_the_groups_instead_of_the_side_nav() {
let narrow = frame_metrics(PaneKit::Supporting, 700.0);
assert_eq!(narrow.layout, PageLayout::SingleColumn);
assert!(narrow.secondary.is_none(), "窄窗不许留侧列");
}
/// M93:设置页「外观」分组的下标与 `pages::appearance` 声明的常量必须一致。
///
/// 抓得住:谁把 `GROUPS` 的顺序调了却忘了改 `APPEARANCE_GROUP`(那样真控件会画到别的分组上,
/// 而支持列显示的分组名也对不上)。
/// 抓不住:控件本身画得对不对(那由截图与 app 侧测试管)。
#[test]
fn the_appearance_group_is_where_the_module_says_it_is() {
assert_eq!(GROUPS[APPEARANCE_GROUP].name, GROUP_LABEL);
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}
/// M99:拆分后**左列顺序**必须还是拆分前那四项(顺序变了就是观感变了,不是「纯搬移」)。
///
/// 抓得住:① 注册表顺序被调换(`GROUPS` 是四条线各自文件拼出来的,最容易在合并时错位);
/// ② 某组被误删/误加(长度不等)。
/// 抓不住:组内那些行还在不在、译文对不对(各组自己的文件与 `catalog` 金表各管一段)。
#[test]
fn the_split_kept_the_left_column_order() {
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
assert_eq!(names, vec!["General", "Appearance", "Java", "Downloads"]);
}
/// M99:「外观」那一组之所以能画真控件,是因为**它自己的文件**挂上了 `body`。
///
/// 抓得住:拆分时把 `body: Some(…)` 搬丢 / 搬到了别的组(那会让真控件消失,退回演示行——
/// 而截图对这种「只在有外观状态时出现」的差别不一定抓得住,见报告「没验的部分」)。
/// 抓不住:`body` 指向的那个渲染器画得对不对(那是 `pages/appearance` 的事)。
#[test]
fn only_the_appearance_group_carries_a_real_control_body() {
assert!(
GROUPS[APPEARANCE_GROUP].body.is_some(),
"外观分组必须是接了真控件的那一组"
);
for (index, group) in GROUPS.iter().enumerate() {
if index == APPEARANCE_GROUP {
continue;
}
assert!(
group.body.is_none(),
"{} 现在应该还是演示行(真控件由它自己那条 section 线接)",
group.name
);
}
}
+1
View File
@@ -3,6 +3,7 @@
//! 每条断言都注明了「抓得住什么 / 抓不住什么」——只测到纯函数,渲染真像素另见报告里的图与量测。
use super::layout::{self, frame_metrics, measure_cap_px, PaneKit, WidthClass};
use super::nav::NAV;
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
+85 -122
View File
@@ -23,6 +23,44 @@
//! 表格是手写的,缺条目的风险回来了;
//! 2. **回归守门**:万一有人把这张表改回「两套各写一遍」,[`Catalog::missing`] 的守卫测试立刻红。
//!
//! ## 按域拆开(M99 `SETTINGSFRAME`)
//!
//! 条目表原先整张写在本文件里,而**设置页正文接下来由四条 mission 并行接**(java / download /
//! language+appearance / ai+about),每条都要往这张表里加一批 key——同一张表就是四条线的冲突点。
//! 现在一个域一个文件(`catalog/<域>.rs`),本文件只留:`catalog!` 宏、装配链、[`Catalog`] 与对外 API。
//!
//! | 文件 | 域 | 谁会长它 |
//! |---|---|---|
//! | [`nav`] / [`instances`] / [`loader`] / [`launch`] / [`font`] / [`core_text`] | 页面与运行时文案 | 各自页面 |
//! | [`download`] / [`logs`] | 下载页 / 日志页(M94 / M95) | 那两页 |
//! | `settings/`(`general` / `appearance` / `java` / `download` / `ai`) | 设置页,**一个分组一个文件** | 设置页四条线 |
//!
//! ### 域文件的形状(加 key 时照抄)
//!
//! ```ignore
//! macro_rules! <域>_entries {
//! ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
//! $next! { $($rest,)* {
//! $($acc)*
//! NavInstances => ("Instances", "实例"),
//! } }
//! };
//! }
//! pub(super) use <域>_entries;
//! ```
//!
//! 它把本域条目**追加**到累积表 `$acc` 之后,再把「还没装配的域」原样交给 `$next`——最后交给
//! [`finish`],由它把整张表喂给 [`catalog!`]。**为什么非得这么绕**:`macro_rules!` 不能在
//! 宏参数里展开另一个宏(`catalog! { nav_entries!() }` 只会把 `nav_entries!()` 当成普通 token),
//! 也不能用元变量当宏名(`macro_rules! $name` 非法),所以只能「回调链」式累积。代价是每个域文件
//! 都要重抄上面那 6 行签名——这是 `macro_rules!` 的固有成本,不是可以省掉的样板。
//!
//! ### 拆开之后仍然只有一个真相源
//!
//! 装配顺序写在下面那条调用里,**它等于 `Key` 的声明顺序**;[`golden`] 把拆分前那份
//! `key -> (en, zh-CN)` 全量逐字节钉住(连顺序一起钉),所以「搬丢一条 / 两条互抄 / 顺序被调换」
//! 都会红。
//!
//! ## 缺译时给什么
//!
//! **不回落**(绝不拿另一种语言顶替,用户会看到「一半英文一半中文」的界面而不自知):
@@ -66,132 +104,59 @@ macro_rules! catalog {
};
}
catalog! {
// ── 导航轨(`pages::Route` 的四项)
NavInstances => ("Instances", "实例"),
NavDownload => ("Downloads", "下载"),
NavLogs => ("Logs", "日志"),
NavSettings => ("Settings", "设置"),
// --------------------------------------------------------------- 域装配
// ── 实例页
InstancesNew => ("New instance", "新建实例"),
InstancesImport => ("Import", "导入"),
InstancesBack => ("Back to instances", "返回实例列表"),
InstancesPlay => ("Play", "启动"),
InstancesLastPlayed => ("Last played {when}", "上次游玩 {when}"),
InstancesEmptyTitle => ("No instance selected", "未选择实例"),
InstancesEmptyBody => ("Pick an instance to see its details.", "选择一个实例查看详情。"),
/// 链的终点:把累积起来的所有条目交给 [`catalog!`],生成 `Key` 与两张表。
macro_rules! finish {
({ $($acc:tt)* }) => { catalog! { $($acc)* } };
}
// ── 加载器(品牌名不翻译:Fabric / Forge / Quilt 一律原样,只有「原版」是词)
LoaderVanilla => ("Vanilla", "原版"),
mod core_text;
mod download;
mod font;
mod instances;
mod launch;
mod loader;
mod logs;
mod nav;
mod settings;
// ── 启动结局(原来 UI 把 "Launch failed" 与 core 的「静默失败」拼成一句话,现在一句话 = 一个 key)
LaunchOutcomeNormal => ("Finished normally", "正常结束"),
LaunchOutcomeCrash => ("The game crashed", "游戏崩溃"),
LaunchOutcomeSilent => (
"Suspected silent failure: exit code 0, but the game never really started",
"疑似静默失败:退出码 0,但游戏没真正跑起来"
),
LaunchFailedWithCode => ("Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
#[cfg(test)]
mod golden;
// ── 中文字体(D28:中文环境缺字体时的下载提示 / 失败回退)
FontCjkNeedsDownload => (
"No Chinese font found on this system. Download an open-source (OFL) Chinese font?",
"系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"
),
FontCjkDownloading => ("Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"),
FontCjkDownloadFailed => (
"Chinese font download failed; the interface stays in English.",
"中文字体下载失败,界面暂时保持英文。"
),
FontCjkReady => ("Chinese font ready: {family}", "中文字体已就绪:{family}"),
#[cfg(test)]
mod tests;
// ── 设置
SettingsLanguage => ("Language", "界面语言"),
SettingsLanguageHint => (
"Switches the interface language. Chinese text needs a Chinese font.",
"切换界面语言。显示中文需要中文字体。"
),
use core_text::core_text_entries;
use download::download_entries;
use font::font_entries;
use instances::instances_entries;
use launch::launch_entries;
use loader::loader_entries;
use logs::logs_entries;
use nav::nav_entries;
use settings::{
settings_ai_entries, settings_appearance_entries, settings_download_entries,
settings_general_entries, settings_java_entries,
};
// ── core / presenter 只给了中文串、UI 又不许画原文时的兜底(见 `bridge`)
CoreTextUntranslated => ("[system text not translated]", "[未翻译的系统文案]"),
// --- download page (M94) ---
// 下载页(`crates/app-iced/src/pages/download`)。一句话一个 key:带变量的句子用 `{name}`,
// 页面只做 `Catalog::render`,不拼片段(`bridge` 的规矩 1)。
//
// 文案口径(M94 r1 / P2-2):**只说这个页面真能做到的动作**(切分类、刷新、看日志),
// 不写「稍后会自动重试」这类 UI 没有的能力,也不拿文案去补功能。
DownloadTitle => ("Downloads", "下载"),
DownloadSubtitle => ("Versions you can install", "可以安装的版本"),
DownloadRefresh => ("Refresh", "刷新"),
DownloadInstall => ("Install", "安装"),
DownloadCancel => ("Cancel install", "取消安装"),
DownloadInstalled => ("Installed", "已安装"),
DownloadCount => ("{count} versions", "{count} 个版本"),
DownloadRowSupporting => ("{kind} · released {date}", "{kind} · 发布于 {date}"),
DownloadSelectHint => ("Pick a version, then install", "选一个版本再安装"),
DownloadLoading => ("Loading versions…", "正在加载版本列表…"),
DownloadLoadingHint => (
"If this stays here, press Refresh in the top right.",
"如果一直停在这里,点右上角的刷新。"
),
DownloadEmptyTitle => ("No versions to show", "没有可显示的版本"),
DownloadEmptyBody => (
"Pick another category, or refresh the list.",
"换一个分类,或刷新一次列表。"
),
DownloadCategoryAll => ("All", "全部"),
DownloadCategoryRelease => ("Release", "正式版"),
DownloadCategorySnapshot => ("Snapshot", "预览版"),
DownloadCategoryAncient => ("Ancient", "远古版"),
DownloadStagePlanning => ("Planning", "解析版本"),
DownloadStageDownloading => ("Downloading files", "下载文件"),
DownloadStageInstalling => ("Building the version", "生成版本"),
DownloadStageRegistering => ("Registering the instance", "登记实例"),
DownloadStageDone => ("Done", "已完成"),
DownloadStageFailed => ("Install failed", "安装失败"),
DownloadStageCancelled => ("Install cancelled", "安装已取消"),
DownloadProgress => (
"{done}/{total} files",
"{done}/{total} 文件"
),
DownloadProgressUnknown => ("Waiting for the first file…", "等第一个文件…"),
// 速率与剩余时间是两个独立短语,各占一个 key(`DownloadRate` 只负责把它们排成一行)
DownloadRate => ("{speed} · {eta}", "{speed},{eta}"),
DownloadEtaSecs => ("{secs}s left", "剩 {secs}s"),
DownloadEtaMins => ("{mins}m{secs}s left", "剩 {mins}m{secs}s"),
DownloadFailedHint => (
"The Logs page has the full reason.",
"完整原因在日志页。"
),
DownloadOpenLogs => ("Open logs", "打开日志"),
DownloadDismiss => ("Dismiss", "关闭"),
// --- logs page (M95) ---
// 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里:
// 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。
// `LogsStatus` 是**模板**:中英差别落在分隔符上(中文用全角竖线——`·` 是外来符号,
// 中文正文里偏挤;这也让「中文目录必须与英文不同」这条守卫成立,不是为过测试硬造差异)。
LogsSubtitleIdle => ("Nothing is running right now", "当前没有运行中的实例"),
LogsStatus => ("{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
LogsOpenFolder => ("Open folder", "打开目录"),
LogsClear => ("Clear", "清空"),
LogsOutputTitle => ("OUTPUT", "输出"),
LogsDiagnosisTitle => ("DIAGNOSIS", "诊断"),
LogsConfidenceHigh => ("HIGH CONFIDENCE", "高置信度"),
LogsConfidenceMedium => ("MEDIUM CONFIDENCE", "中置信度"),
LogsConfidenceLow => ("LOW CONFIDENCE", "低置信度"),
LogsSubtitleLive => ("Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"),
LogsConfidence => ("Confidence {percent}%", "置信度 {percent}%"),
LogsSuggestedFix => ("Suggested fix", "建议动作"),
LogsSummaryLabel => ("One-line summary", "一行摘要"),
LogsCopySummary => ("Copy summary", "复制摘要"),
LogsEmptyTitle => ("No output yet", "还没有输出"),
LogsEmptyBody => (
"Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.",
"启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"
),
LogsEmptyAction => ("Choose an instance", "选择实例"),
// 装配顺序 = `Key` 的声明顺序。**与拆分前逐条一致**,动它等于动 `Key` 的 `Ord` 与导出表顺序,
// 金表(`catalog/golden.rs`)会红。设置页那五个域按页面上的分组顺序排,中间三个现在是空的。
nav_entries! {
instances_entries,
loader_entries,
launch_entries,
font_entries,
settings_general_entries,
settings_appearance_entries,
settings_java_entries,
settings_download_entries,
settings_ai_entries,
core_text_entries,
download_entries,
logs_entries,
finish,
{}
}
/// 一套语言的目录(不可变;`Copy`,随手传值)。
@@ -306,5 +271,3 @@ pub fn placeholders(text: &str) -> Vec<&str> {
}
found
}
#[cfg(test)]
mod tests;
+15
View File
@@ -0,0 +1,15 @@
//! core / presenter 只给了中文串、UI 又不许画原文时的兜底文案(口径见 [`crate::bridge`])。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! core_text_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── core / presenter 只给了中文串、UI 又不许画原文时的兜底(见 `bridge`)
CoreTextUntranslated => ("[system text not translated]", "[未翻译的系统文案]"),
} }
};
}
pub(crate) use core_text_entries;
+65
View File
@@ -0,0 +1,65 @@
//! 下载页文案(M94)。
//!
//! 一句话一个 key:带变量的句子用 `{name}`,页面只做 [`crate::Catalog::render`],不拼片段
//! ([`crate::bridge`] 的规矩 1)。
//!
//! 文案口径(M94 r1 / P2-2):**只说这个页面真能做到的动作**(切分类、刷新、看日志),
//! 不写「稍后会自动重试」这类 UI 没有的能力,也不拿文案去补功能。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! download_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// --- download page (M94) ---
DownloadTitle => ("Downloads", "下载"),
DownloadSubtitle => ("Versions you can install", "可以安装的版本"),
DownloadRefresh => ("Refresh", "刷新"),
DownloadInstall => ("Install", "安装"),
DownloadCancel => ("Cancel install", "取消安装"),
DownloadInstalled => ("Installed", "已安装"),
DownloadCount => ("{count} versions", "{count} 个版本"),
DownloadRowSupporting => ("{kind} · released {date}", "{kind} · 发布于 {date}"),
DownloadSelectHint => ("Pick a version, then install", "选一个版本再安装"),
DownloadLoading => ("Loading versions…", "正在加载版本列表…"),
DownloadLoadingHint => (
"If this stays here, press Refresh in the top right.",
"如果一直停在这里,点右上角的刷新。"
),
DownloadEmptyTitle => ("No versions to show", "没有可显示的版本"),
DownloadEmptyBody => (
"Pick another category, or refresh the list.",
"换一个分类,或刷新一次列表。"
),
DownloadCategoryAll => ("All", "全部"),
DownloadCategoryRelease => ("Release", "正式版"),
DownloadCategorySnapshot => ("Snapshot", "预览版"),
DownloadCategoryAncient => ("Ancient", "远古版"),
DownloadStagePlanning => ("Planning", "解析版本"),
DownloadStageDownloading => ("Downloading files", "下载文件"),
DownloadStageInstalling => ("Building the version", "生成版本"),
DownloadStageRegistering => ("Registering the instance", "登记实例"),
DownloadStageDone => ("Done", "已完成"),
DownloadStageFailed => ("Install failed", "安装失败"),
DownloadStageCancelled => ("Install cancelled", "安装已取消"),
DownloadProgress => (
"{done}/{total} files",
"{done}/{total} 文件"
),
DownloadProgressUnknown => ("Waiting for the first file…", "等第一个文件…"),
// 速率与剩余时间是两个独立短语,各占一个 key(`DownloadRate` 只负责把它们排成一行)
DownloadRate => ("{speed} · {eta}", "{speed},{eta}"),
DownloadEtaSecs => ("{secs}s left", "剩 {secs}s"),
DownloadEtaMins => ("{mins}m{secs}s left", "剩 {mins}m{secs}s"),
DownloadFailedHint => (
"The Logs page has the full reason.",
"完整原因在日志页。"
),
DownloadOpenLogs => ("Open logs", "打开日志"),
DownloadDismiss => ("Dismiss", "关闭"),
} }
};
}
pub(crate) use download_entries;
+24
View File
@@ -0,0 +1,24 @@
//! 中文字体文案(D28):中文环境缺字体时的下载提示 / 失败回退。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! font_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 中文字体(D28:中文环境缺字体时的下载提示 / 失败回退)
FontCjkNeedsDownload => (
"No Chinese font found on this system. Download an open-source (OFL) Chinese font?",
"系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"
),
FontCjkDownloading => ("Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"),
FontCjkDownloadFailed => (
"Chinese font download failed; the interface stays in English.",
"中文字体下载失败,界面暂时保持英文。"
),
FontCjkReady => ("Chinese font ready: {family}", "中文字体已就绪:{family}"),
} }
};
}
pub(crate) use font_entries;
+97
View File
@@ -0,0 +1,97 @@
//! 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。
//!
//! ## 它是什么
//!
//! M99 把 `catalog.rs` 的单张条目表按域拆到 `catalog/<域>.rs` 之后,这 72 行就是「拆分前那一份」
//! 的原样导出:由拆分前的 `Catalog::of(En)` / `Catalog::of(ZhCn)` 逐条 `println!` 出来,
//! 再原封不动抄进来(生成方式见 `catalog/tests.rs` 里 `split_keeps_every_translation` 的注释)。
//!
//! ## 为什么不是「数条数」
//!
//! 条数相等抓不住任何一种真会发生的坏法:某条译文在搬移时被漏掉、另一条被复制两次,条数照旧。
//! 这张表比的是**全量、逐字节、按声明顺序**的三元组,于是下面四种坏法都会红:
//!
//! | 坏法 | 红在哪 |
//! |---|---|
//! | 某个 key 整条被搬丢 | 条数不等 / 该行 key 名对不上 |
//! | 两条 key 的译文互抄(同 key 被覆盖到别的译文) | 该行 en 或 zh 不等 |
//! | 某条译文改了一个字符 | 该行不等(`assert_eq!` 带原文) |
//! | 域的顺序被调换(`Key` 的 `Ord` 与导出表顺序都靠它) | 该行 key 名对不上 |
//!
//! **抓不住**:译文本身翻得对不对(金表是「拆分前」的快照,它只能证明「没变」)。
/// 拆分前 `Key::ALL` 的顺序 × (en, zh-CN)。第一列是变体名(`Key::name()` 的返回值)。
pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("NavInstances", "Instances", "实例"),
("NavDownload", "Downloads", "下载"),
("NavLogs", "Logs", "日志"),
("NavSettings", "Settings", "设置"),
("InstancesNew", "New instance", "新建实例"),
("InstancesImport", "Import", "导入"),
("InstancesBack", "Back to instances", "返回实例列表"),
("InstancesPlay", "Play", "启动"),
("InstancesLastPlayed", "Last played {when}", "上次游玩 {when}"),
("InstancesEmptyTitle", "No instance selected", "未选择实例"),
("InstancesEmptyBody", "Pick an instance to see its details.", "选择一个实例查看详情。"),
("LoaderVanilla", "Vanilla", "原版"),
("LaunchOutcomeNormal", "Finished normally", "正常结束"),
("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"),
("LaunchOutcomeSilent", "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来"),
("LaunchFailedWithCode", "Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
("FontCjkNeedsDownload", "No Chinese font found on this system. Download an open-source (OFL) Chinese font?", "系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"),
("FontCjkDownloading", "Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"),
("FontCjkDownloadFailed", "Chinese font download failed; the interface stays in English.", "中文字体下载失败,界面暂时保持英文。"),
("FontCjkReady", "Chinese font ready: {family}", "中文字体已就绪:{family}"),
("SettingsLanguage", "Language", "界面语言"),
("SettingsLanguageHint", "Switches the interface language. Chinese text needs a Chinese font.", "切换界面语言。显示中文需要中文字体。"),
("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"),
("DownloadTitle", "Downloads", "下载"),
("DownloadSubtitle", "Versions you can install", "可以安装的版本"),
("DownloadRefresh", "Refresh", "刷新"),
("DownloadInstall", "Install", "安装"),
("DownloadCancel", "Cancel install", "取消安装"),
("DownloadInstalled", "Installed", "已安装"),
("DownloadCount", "{count} versions", "{count} 个版本"),
("DownloadRowSupporting", "{kind} · released {date}", "{kind} · 发布于 {date}"),
("DownloadSelectHint", "Pick a version, then install", "选一个版本再安装"),
("DownloadLoading", "Loading versions…", "正在加载版本列表…"),
("DownloadLoadingHint", "If this stays here, press Refresh in the top right.", "如果一直停在这里,点右上角的刷新。"),
("DownloadEmptyTitle", "No versions to show", "没有可显示的版本"),
("DownloadEmptyBody", "Pick another category, or refresh the list.", "换一个分类,或刷新一次列表。"),
("DownloadCategoryAll", "All", "全部"),
("DownloadCategoryRelease", "Release", "正式版"),
("DownloadCategorySnapshot", "Snapshot", "预览版"),
("DownloadCategoryAncient", "Ancient", "远古版"),
("DownloadStagePlanning", "Planning", "解析版本"),
("DownloadStageDownloading", "Downloading files", "下载文件"),
("DownloadStageInstalling", "Building the version", "生成版本"),
("DownloadStageRegistering", "Registering the instance", "登记实例"),
("DownloadStageDone", "Done", "已完成"),
("DownloadStageFailed", "Install failed", "安装失败"),
("DownloadStageCancelled", "Install cancelled", "安装已取消"),
("DownloadProgress", "{done}/{total} files", "{done}/{total} 文件"),
("DownloadProgressUnknown", "Waiting for the first file…", "等第一个文件…"),
("DownloadRate", "{speed} · {eta}", "{speed},{eta}"),
("DownloadEtaSecs", "{secs}s left", "剩 {secs}s"),
("DownloadEtaMins", "{mins}m{secs}s left", "剩 {mins}m{secs}s"),
("DownloadFailedHint", "The Logs page has the full reason.", "完整原因在日志页。"),
("DownloadOpenLogs", "Open logs", "打开日志"),
("DownloadDismiss", "Dismiss", "关闭"),
("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"),
("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
("LogsOpenFolder", "Open folder", "打开目录"),
("LogsClear", "Clear", "清空"),
("LogsOutputTitle", "OUTPUT", "输出"),
("LogsDiagnosisTitle", "DIAGNOSIS", "诊断"),
("LogsConfidenceHigh", "HIGH CONFIDENCE", "高置信度"),
("LogsConfidenceMedium", "MEDIUM CONFIDENCE", "中置信度"),
("LogsConfidenceLow", "LOW CONFIDENCE", "低置信度"),
("LogsSubtitleLive", "Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"),
("LogsConfidence", "Confidence {percent}%", "置信度 {percent}%"),
("LogsSuggestedFix", "Suggested fix", "建议动作"),
("LogsSummaryLabel", "One-line summary", "一行摘要"),
("LogsCopySummary", "Copy summary", "复制摘要"),
("LogsEmptyTitle", "No output yet", "还没有输出"),
("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"),
("LogsEmptyAction", "Choose an instance", "选择实例"),
];
+21
View File
@@ -0,0 +1,21 @@
//! 实例页文案:页头动作、返回、上次游玩时间、空态。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! instances_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 实例页
InstancesNew => ("New instance", "新建实例"),
InstancesImport => ("Import", "导入"),
InstancesBack => ("Back to instances", "返回实例列表"),
InstancesPlay => ("Play", "启动"),
InstancesLastPlayed => ("Last played {when}", "上次游玩 {when}"),
InstancesEmptyTitle => ("No instance selected", "未选择实例"),
InstancesEmptyBody => ("Pick an instance to see its details.", "选择一个实例查看详情。"),
} }
};
}
pub(crate) use instances_entries;
+21
View File
@@ -0,0 +1,21 @@
//! 启动结局文案:一句话 = 一个 key(原来 UI 把 `"Launch failed"` 与 core 的「静默失败」拼成一句话)。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! launch_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 启动结局(原来 UI 把 "Launch failed" 与 core 的「静默失败」拼成一句话,现在一句话 = 一个 key)
LaunchOutcomeNormal => ("Finished normally", "正常结束"),
LaunchOutcomeCrash => ("The game crashed", "游戏崩溃"),
LaunchOutcomeSilent => (
"Suspected silent failure: exit code 0, but the game never really started",
"疑似静默失败:退出码 0,但游戏没真正跑起来"
),
LaunchFailedWithCode => ("Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
} }
};
}
pub(crate) use launch_entries;
+15
View File
@@ -0,0 +1,15 @@
//! 加载器文案:品牌名不翻译(Fabric / Forge / Quilt 一律原样),只有「原版」是一个词。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! loader_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 加载器(品牌名不翻译:Fabric / Forge / Quilt 一律原样,只有「原版」是词)
LoaderVanilla => ("Vanilla", "原版"),
} }
};
}
pub(crate) use loader_entries;
+40
View File
@@ -0,0 +1,40 @@
//! 日志页文案(M95)。
//!
//! 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里:
//! 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。
//!
//! `LogsStatus` 是**模板**:中英差别落在分隔符上(中文用全角竖线——`·` 是外来符号,
//! 中文正文里偏挤;这也让「中文目录必须与英文不同」这条守卫成立,不是为过测试硬造差异)。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! logs_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// --- logs page (M95) ---
LogsSubtitleIdle => ("Nothing is running right now", "当前没有运行中的实例"),
LogsStatus => ("{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
LogsOpenFolder => ("Open folder", "打开目录"),
LogsClear => ("Clear", "清空"),
LogsOutputTitle => ("OUTPUT", "输出"),
LogsDiagnosisTitle => ("DIAGNOSIS", "诊断"),
LogsConfidenceHigh => ("HIGH CONFIDENCE", "高置信度"),
LogsConfidenceMedium => ("MEDIUM CONFIDENCE", "中置信度"),
LogsConfidenceLow => ("LOW CONFIDENCE", "低置信度"),
LogsSubtitleLive => ("Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"),
LogsConfidence => ("Confidence {percent}%", "置信度 {percent}%"),
LogsSuggestedFix => ("Suggested fix", "建议动作"),
LogsSummaryLabel => ("One-line summary", "一行摘要"),
LogsCopySummary => ("Copy summary", "复制摘要"),
LogsEmptyTitle => ("No output yet", "还没有输出"),
LogsEmptyBody => (
"Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.",
"启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"
),
LogsEmptyAction => ("Choose an instance", "选择实例"),
} }
};
}
pub(crate) use logs_entries;
+19
View File
@@ -0,0 +1,19 @@
//! 导航轨文案:四个一级页。AI 独立页没有导航高亮项(`Route::Agent.nav()` 是 `None`),所以没有 key。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节:本宏把条目**追加**到 `$acc` 之后,
//! 再把「还没装配的域」原样交给 `$next`。条目顺序 = `Key` 的声明顺序(金表 `catalog::golden` 守门)。
macro_rules! nav_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 导航轨(`pages::Route` 的四项)
NavInstances => ("Instances", "实例"),
NavDownload => ("Downloads", "下载"),
NavLogs => ("Logs", "日志"),
NavSettings => ("Settings", "设置"),
} }
};
}
pub(crate) use nav_entries;
+13
View File
@@ -0,0 +1,13 @@
//! 设置页「AI / About」分组文案:**预留**(页面上还没有这个分组,见 `pages/settings/mod.rs` 的
//! 分组注册表)。
//!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。
macro_rules! settings_ai_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* { $($acc)* } }
};
}
pub(crate) use settings_ai_entries;
@@ -0,0 +1,12 @@
//! 设置页「Appearance」分组文案:**预留**(真控件在 `pages/appearance/section.rs`,文案还没接 i18n)。
//!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。
macro_rules! settings_appearance_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* { $($acc)* } }
};
}
pub(crate) use settings_appearance_entries;
@@ -0,0 +1,12 @@
//! 设置页「Downloads」分组文案:**预留**(页面侧的真控件还没接)。
//!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。
macro_rules! settings_download_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* { $($acc)* } }
};
}
pub(crate) use settings_download_entries;
@@ -0,0 +1,19 @@
//! 设置页「General」分组文案:语言(Theme / Startup page / Close button 的 key 还没建)。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! settings_general_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 设置
SettingsLanguage => ("Language", "界面语言"),
SettingsLanguageHint => (
"Switches the interface language. Chinese text needs a Chinese font.",
"切换界面语言。显示中文需要中文字体。"
),
} }
};
}
pub(crate) use settings_general_entries;
+12
View File
@@ -0,0 +1,12 @@
//! 设置页「Java」分组文案:**预留**(页面侧的真控件还没接)。
//!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。
macro_rules! settings_java_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* { $($acc)* } }
};
}
pub(crate) use settings_java_entries;
+28
View File
@@ -0,0 +1,28 @@
//! 设置页文案:**一个分组一个文件**,与页面侧的 `pages/settings/**` 同构。
//!
//! 为什么拆到这个粒度:设置页正文接下来由四条并行 mission 分别接(java / download /
//! language+appearance / ai+about),它们都会往设置页加 key。一个分组一个文件之后,每条线只改
//! 自己那个文件,`catalog.rs` 的装配链一行都不用动(链里已经把这五个域接好了)。
//!
//! | 文件 | 分组(`pages/settings` 的 `GROUPS`) | 现状 |
//! |---|---|---|
//! | [`general`] | General(Language / Theme / Startup page / Close button) | 2 条 key(语言) |
//! | [`appearance`] | Appearance(Density / Corner style / Motion / Text scale) | 预留,暂无 key |
//! | [`java`] | Java(Runtime / Default memory / JVM arguments) | 预留,暂无 key |
//! | [`download`] | Downloads(Preferred source / Concurrency / Verify checksums) | 预留,暂无 key |
//! | [`ai`] | AI / About(页面上还没有这个分组) | 预留,暂无 key |
//!
//! 「预留」的文件里是**空转移器**(只把累积表原样交给下一个域):加 key 时把条目写进它
//! `$($acc)*` 后面即可——签名不用改,`catalog.rs` 不用碰,跨线零冲突。
mod ai;
mod appearance;
mod download;
mod general;
mod java;
pub(crate) use ai::settings_ai_entries;
pub(crate) use appearance::settings_appearance_entries;
pub(crate) use download::settings_download_entries;
pub(crate) use general::settings_general_entries;
pub(crate) use java::settings_java_entries;
+37
View File
@@ -162,3 +162,40 @@ fn render_substitutes_and_keeps_unfilled_placeholders_visible() {
assert_eq!(zh.text(Key::InstancesNew), "新建实例");
assert_eq!(zh.lang(), Lang::ZhCn);
}
/// M99:把单张条目表按域拆到 `catalog/<域>.rs` 之后,`key -> (en, zh-CN)` 必须**逐条逐字节**不变。
///
/// **抓得住**:① 某个 key 整条被搬丢(金表有、现在没有 ⇒ 条数或 key 名对不上);② 两条 key 的译文
/// 互抄 / 同 key 被覆盖成别人的译文(该行 en 或 zh 不等);③ 某条译文改了一个字符(逐字节比,
/// **不是**数条数——「A 丢了一条、B 复制成两条」条数照旧);④ 域的顺序被调换(`Key` 的 `Ord` 与
/// `entries()` 的声明顺序都靠它,金表按原顺序比)。
/// **抓不住**:译文本身翻得对不对——金表是「拆分前那一份」的快照,只能证明没变,不能证明翻得好。
///
/// 金表怎么来的:拆分前在这里临时加过一条 `println!("GOLD\t{}\t{}\t{}", …)` 的用例,把
/// `Key::ALL` ×(`Catalog::of(En)`, `Catalog::of(ZhCn)`)导出成 72 行 TSV,再原样抄成
/// `catalog/golden.rs`;那条导出用例随后已删除(不在提交里)。
#[test]
fn split_keeps_every_translation() {
let en = Catalog::of(Lang::En);
let zh = Catalog::of(Lang::ZhCn);
let actual = Key::ALL
.iter()
.map(|key| (key.name(), en.text(*key), zh.text(*key)))
.collect::<Vec<_>>();
assert_eq!(
actual.len(),
super::golden::GOLDEN.len(),
"key 条数与拆分前不一致(某条被搬丢,或多出来一条)"
);
for (index, expected) in super::golden::GOLDEN.iter().enumerate() {
let got = actual[index];
assert_eq!(
got.0, expected.0,
"第 {index} 条的 key 变了:拆分的域顺序被打乱,或某条被搬到了别处"
);
assert_eq!(got.1, expected.1, "{} 的英文译文变了", expected.0);
assert_eq!(got.2, expected.2, "{} 的中文译文变了", expected.0);
}
}