refactor(pages,i18n): M99 纯搬移 —— 导航表抽 pages/nav.rs、设置页拆目录、catalog 按域拆开

- pages/mod.rs 400 -> 356 行:NAV 表与组件版 rail 搬进 pages/nav.rs(NAV 可见性变 pub(crate),
  只为让 pages/tests.rs 的三条断言原样保留)
- pages/settings.rs -> pages/settings/{mod,frame,general,appearance,java,download,ai,tests}.rs:
  mod.rs 只留「分组注册表 + 选中分组 + 内容分发」,一组一个文件,为四条 section 线各留位置
- i18n catalog.rs 310 -> 273 行:条目表按域拆到 catalog/<域>.rs,catalog.rs 留 catalog! 宏、
  装配链、Catalog 与对外 API;新增 catalog/golden.rs(拆分前 key->译文 全量快照)守门
- 行为/文案/样式零变化:pages_demo 的 instances/settings × 浅/深 四张图逐字节相同
This commit is contained in:
AskaEth committed 2026-11-03 13:19:17 +08:00
1 parent 4b1471a60d
commit 330f7216fd
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;