diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 460a3bd..c544d65 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -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, 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) -> 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 键 + 拖动热区), diff --git a/crates/app-iced/src/pages/nav.rs b/crates/app-iced/src/pages/nav.rs new file mode 100644 index 0000000..8659635 --- /dev/null +++ b/crates/app-iced/src/pages/nav.rs @@ -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, 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) -> 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)) +} diff --git a/crates/app-iced/src/pages/settings.rs b/crates/app-iced/src/pages/settings.rs deleted file mode 100644 index 29acab8..0000000 --- a/crates/app-iced/src/pages/settings.rs +++ /dev/null @@ -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::>(), - ) - .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, - 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(), "演示下标仍在范围内"); - } -} diff --git a/crates/app-iced/src/pages/settings/ai.rs b/crates/app-iced/src/pages/settings/ai.rs new file mode 100644 index 0000000..f7c62f5 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai.rs @@ -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`(那边已经接进装配链,同样只改一个文件)。 +//! +//! 界面上「多了一组」这件事本身是行为变化,所以**只在真有那一组内容时才做**,不要为占位先加。 diff --git a/crates/app-iced/src/pages/settings/appearance.rs b/crates/app-iced/src/pages/settings/appearance.rs new file mode 100644 index 0000000..85e0b41 --- /dev/null +++ b/crates/app-iced/src/pages/settings/appearance.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), +}; diff --git a/crates/app-iced/src/pages/settings/download.rs b/crates/app-iced/src/pages/settings/download.rs new file mode 100644 index 0000000..1bb7420 --- /dev/null +++ b/crates/app-iced/src/pages/settings/download.rs @@ -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, +}; diff --git a/crates/app-iced/src/pages/settings/frame.rs b/crates/app-iced/src/pages/settings/frame.rs new file mode 100644 index 0000000..d3f99ce --- /dev/null +++ b/crates/app-iced/src/pages/settings/frame.rs @@ -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::>(), + ) + .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> { + 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() +} diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs new file mode 100644 index 0000000..9fcd2ee --- /dev/null +++ b/crates/app-iced/src/pages/settings/general.rs @@ -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, +}; diff --git a/crates/app-iced/src/pages/settings/java.rs b/crates/app-iced/src/pages/settings/java.rs new file mode 100644 index 0000000..fe2bda7 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java.rs @@ -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, +}; diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs new file mode 100644 index 0000000..745416d --- /dev/null +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -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, +} + +/// 分组注册表:**顺序 = 左列自上而下**,也是 `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, + 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) +} diff --git a/crates/app-iced/src/pages/settings/tests.rs b/crates/app-iced/src/pages/settings/tests.rs new file mode 100644 index 0000000..8d413a5 --- /dev/null +++ b/crates/app-iced/src/pages/settings/tests.rs @@ -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 + ); + } +} diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs index c6b088f..1eb2c99 100644 --- a/crates/app-iced/src/pages/tests.rs +++ b/crates/app-iced/src/pages/tests.rs @@ -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; diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index 469c2e8..dbf16a7 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -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; diff --git a/crates/i18n/src/catalog/core_text.rs b/crates/i18n/src/catalog/core_text.rs new file mode 100644 index 0000000..e7ad863 --- /dev/null +++ b/crates/i18n/src/catalog/core_text.rs @@ -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; diff --git a/crates/i18n/src/catalog/download.rs b/crates/i18n/src/catalog/download.rs new file mode 100644 index 0000000..a378511 --- /dev/null +++ b/crates/i18n/src/catalog/download.rs @@ -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; diff --git a/crates/i18n/src/catalog/font.rs b/crates/i18n/src/catalog/font.rs new file mode 100644 index 0000000..4fc8f70 --- /dev/null +++ b/crates/i18n/src/catalog/font.rs @@ -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; diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs new file mode 100644 index 0000000..d643333 --- /dev/null +++ b/crates/i18n/src/catalog/golden.rs @@ -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", "选择实例"), +]; diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs new file mode 100644 index 0000000..dd4ca9a --- /dev/null +++ b/crates/i18n/src/catalog/instances.rs @@ -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; diff --git a/crates/i18n/src/catalog/launch.rs b/crates/i18n/src/catalog/launch.rs new file mode 100644 index 0000000..d9e6073 --- /dev/null +++ b/crates/i18n/src/catalog/launch.rs @@ -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; diff --git a/crates/i18n/src/catalog/loader.rs b/crates/i18n/src/catalog/loader.rs new file mode 100644 index 0000000..01947b1 --- /dev/null +++ b/crates/i18n/src/catalog/loader.rs @@ -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; diff --git a/crates/i18n/src/catalog/logs.rs b/crates/i18n/src/catalog/logs.rs new file mode 100644 index 0000000..14716b3 --- /dev/null +++ b/crates/i18n/src/catalog/logs.rs @@ -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; diff --git a/crates/i18n/src/catalog/nav.rs b/crates/i18n/src/catalog/nav.rs new file mode 100644 index 0000000..6263084 --- /dev/null +++ b/crates/i18n/src/catalog/nav.rs @@ -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; diff --git a/crates/i18n/src/catalog/settings/ai.rs b/crates/i18n/src/catalog/settings/ai.rs new file mode 100644 index 0000000..17f2514 --- /dev/null +++ b/crates/i18n/src/catalog/settings/ai.rs @@ -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; diff --git a/crates/i18n/src/catalog/settings/appearance.rs b/crates/i18n/src/catalog/settings/appearance.rs new file mode 100644 index 0000000..d32449e --- /dev/null +++ b/crates/i18n/src/catalog/settings/appearance.rs @@ -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; diff --git a/crates/i18n/src/catalog/settings/download.rs b/crates/i18n/src/catalog/settings/download.rs new file mode 100644 index 0000000..49163e3 --- /dev/null +++ b/crates/i18n/src/catalog/settings/download.rs @@ -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; diff --git a/crates/i18n/src/catalog/settings/general.rs b/crates/i18n/src/catalog/settings/general.rs new file mode 100644 index 0000000..ba523cf --- /dev/null +++ b/crates/i18n/src/catalog/settings/general.rs @@ -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; diff --git a/crates/i18n/src/catalog/settings/java.rs b/crates/i18n/src/catalog/settings/java.rs new file mode 100644 index 0000000..db75b75 --- /dev/null +++ b/crates/i18n/src/catalog/settings/java.rs @@ -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; diff --git a/crates/i18n/src/catalog/settings/mod.rs b/crates/i18n/src/catalog/settings/mod.rs new file mode 100644 index 0000000..b2746d9 --- /dev/null +++ b/crates/i18n/src/catalog/settings/mod.rs @@ -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; diff --git a/crates/i18n/src/catalog/tests.rs b/crates/i18n/src/catalog/tests.rs index 630b0e4..e329bbc 100644 --- a/crates/i18n/src/catalog/tests.rs +++ b/crates/i18n/src/catalog/tests.rs @@ -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::>(); + + 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); + } +}