代码复审 p2-2items / 视觉复审 p2-2items 的修复轮(配色重拍等 M70 定案)。
M69 迁移(2 行)
- nav 轨宽:nav_rail_width(metrics) 函数体改用 metrics.nav_rail_width
- theme::STROKE_PX 改为 pub use unlucky_design::stroke::HAIRLINE as STROKE_PX
P2-1 零硬编码守门补洞(reviewer 造样 5 个漏网样本全部补上,并进自检正向断言)
- COLOR 调用表补 from_rgb8 / rgb8 / rgba(`from_rgb(` 子串匹配不到 `from_rgb8(` 是根因)
- 新增 `Color { r: .., g: .. }` 结构体字面量检查
- 尺寸规则遇到 Length::Fixed( / Padding::from( / Size::new( 往里剥一层再判数字
- 新增渲染层回归守门 pill_never_overrides_its_size_with_fill(禁 nav.rs 出现 center_*( ):
视觉复审 P2-1 的病根就是 center_*(Length::Fill) 把容器尺寸覆盖成 Fill
视觉 P2-1 药丸 88×32 → 56×32(实测 x=16..71)
- 去掉 center_x/center_y(Fill);居中改由父 column 的 align_x + 显式 width(轨宽) 完成
(只去掉 center_* 还不够:column 会 shrink 到 56,药丸贴左缘 x=0..55,是同一个坑的第二层)
视觉 P2-2 / 用户点名 caption 图标 46 → 24(按钮容器保持 46×48)
- button 内包 container(...).width(24).height(24).center(Length::Fill):
只设 width/height 会让容器缩到 24 并贴 button 内容左上角;center(Fill) 才既撑满又居中
视觉 P3-1 东侧/南侧缩放热区改成叠层
- 原来是 row 的兄弟列,吃掉 4px 布局宽(右留白 28、关闭键离窗角 4px);
现在用 stack! + 对齐容器叠在工作区上,布局宽回到 1180(卡片右缘 x=1155 = 右留白 24)
P3 清单
1. --data-dir 接受并忽略(翻牌前 CI 兼容;现役 smoke 命令行可原样跑,exit 0)+ 单测
2. --diff 内建 ±N px 平移扫描(--diff-scan),CI 不再依赖 numpy
3. 截图模式等 SystemTheme 到达才出图(否则暗色系统上会拍到浅色)
4. (Auto, 未编入 wgpu) 返回 tiny-skia,不再把 wgpu 写进候选列表
5. --renderer=gpu 也跳过适配器枚举(决策与探测无关,省 ~0.6~1.2s)
6. icons::handle 加 debug_assert_eq!(IconKind::ALL[kind as usize], kind)
7. 轨顶留白改 container padding-top,实测 24(原来 spacer + column spacing = 28)
顺带:shot.rs 拆成 shot/mod.rs + shot/diff.rs(≤400 行门禁)
门禁:fmt OK / clippy -D warnings 干净 / 46+5 测试全过 / cargo check --workspace --all-targets OK
体积:双后端 6,769,664 B(6.46 MiB)、仅 CPU 3,955,712 B(3.77 MiB)
截图:r1 代码验证图(r1-verify / hover / pressed / compare-nav-rail)已出;4 张交付主图待 M70 定案后重拍
259 lines
9.0 KiB
Rust
259 lines
9.0 KiB
Rust
//! 左侧导航轨(M3 `NavigationRail`)。
|
||
//!
|
||
//! 判据来源 `research/12b-component-fidelity.md` §5:
|
||
//!
|
||
//! | 项 | 参考值 | 令牌落点 |
|
||
//! |---|---|---|
|
||
//! | 轨宽 | 5.1 官方 96(narrow 80);本项目 **88** | [`nav_rail_width`](`LayoutMetrics` 骨架值,详见该函数文档的 M69 迁移说明) |
|
||
//! | 轨容器 | 5.3 形状 None / L0 / `Surface` | `MaterialLayer::Canvas` |
|
||
//! | 选中指示器 | 5.4 **56×32**、圆角 full、`SecondaryContainer` | [`indicator_size`](令牌推导) |
|
||
//! | 图标 | 5.5 24px | `IconSize::Toolbar` |
|
||
//! | 标签 | 5.5 `LabelMedium 12/16` | `TypeRole::LabelMedium` |
|
||
//! | 颜色 | 5.6 选中图标 `OnSecondaryContainer` / 选中标签 `Secondary` / 未选中 `OnSurfaceVariant` | `Ui::color` |
|
||
//! | 项间距 | 5.7 **4** | `LayoutMetrics::nav_item_gap` |
|
||
|
||
use iced::widget::{button, column, container};
|
||
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme};
|
||
|
||
use unlucky_design::color::{Role, StateLayer};
|
||
use unlucky_design::elevation::MaterialLayer;
|
||
use unlucky_design::icons::IconKind;
|
||
use unlucky_design::shape::ShapeScale;
|
||
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||
use unlucky_design::typography::TypeRole;
|
||
|
||
use crate::app::Message;
|
||
use crate::cli::ForcedState;
|
||
use crate::icons;
|
||
use crate::shell::{filled, label, overridden};
|
||
use crate::theme::Ui;
|
||
|
||
/// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum NavItem {
|
||
/// 实例列表。
|
||
Instances,
|
||
/// 下载/版本。
|
||
Download,
|
||
/// 日志。
|
||
Logs,
|
||
/// 设置。
|
||
Settings,
|
||
/// AI 助手。
|
||
Ai,
|
||
}
|
||
|
||
impl NavItem {
|
||
/// 全部占位项(顺序 = 导航栏自上而下)。
|
||
pub const ALL: [NavItem; 5] = [
|
||
NavItem::Instances,
|
||
NavItem::Download,
|
||
NavItem::Logs,
|
||
NavItem::Settings,
|
||
NavItem::Ai,
|
||
];
|
||
|
||
/// 显示名(i18n 是后续 mission;本 mission 用英文基线文案,避免干净 CI 上缺中文变豆腐块)。
|
||
pub const fn label(self) -> &'static str {
|
||
match self {
|
||
NavItem::Instances => "Instances",
|
||
NavItem::Download => "Download",
|
||
NavItem::Logs => "Logs",
|
||
NavItem::Settings => "Settings",
|
||
NavItem::Ai => "Assistant",
|
||
}
|
||
}
|
||
|
||
/// 令牌图标。
|
||
pub const fn icon(self) -> IconKind {
|
||
match self {
|
||
NavItem::Instances => IconKind::Instances,
|
||
NavItem::Download => IconKind::Download,
|
||
NavItem::Logs => IconKind::Logs,
|
||
NavItem::Settings => IconKind::Settings,
|
||
NavItem::Ai => IconKind::Ai,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 整条导航轨。
|
||
pub(super) fn rail<'a>(
|
||
ui: &'a Ui,
|
||
selected: NavItem,
|
||
forced: Option<ForcedState>,
|
||
) -> Element<'a, Message> {
|
||
let metrics = ui.metrics();
|
||
let indicator = indicator_size(metrics);
|
||
|
||
let items = NavItem::ALL
|
||
.iter()
|
||
.map(|item| rail_item(ui, *item, *item == selected, indicator, forced))
|
||
.collect::<Vec<_>>();
|
||
|
||
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
|
||
// spacing(4)再加一档,实测变 28(视觉复审 P3-5)。
|
||
container(column(items).spacing(metrics.nav_item_gap))
|
||
.width(nav_rail_width(metrics))
|
||
.height(Length::Fill)
|
||
.padding(Padding::from([ui.space(Space::X6), 0.0]))
|
||
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||
.into()
|
||
}
|
||
|
||
fn rail_item<'a>(
|
||
ui: &'a Ui,
|
||
item: NavItem,
|
||
selected: bool,
|
||
indicator: (f32, f32),
|
||
forced: Option<ForcedState>,
|
||
) -> Element<'a, Message> {
|
||
let (indicator_width, indicator_height) = indicator;
|
||
|
||
let icon_color = if selected {
|
||
ui.color(Role::OnSecondaryContainer)
|
||
} else {
|
||
ui.color(Role::OnSurfaceVariant)
|
||
};
|
||
// 12b 5.6:选中标签用 `Secondary`,未选中 `OnSurfaceVariant`
|
||
let label_color = if selected {
|
||
ui.color(Role::Secondary)
|
||
} else {
|
||
ui.color(Role::OnSurfaceVariant)
|
||
};
|
||
|
||
// **不要用 `center_x/center_y(Fill)`**:那会把容器自己的 width/height 改成 Fill,
|
||
// 于是 56 被父级(button 宽 88)覆盖成 88、32 靠布局凑巧(视觉复审 P2-1)。
|
||
// 尺寸只认令牌推导值,居中交给父 column 的 `align_x`。
|
||
let pill = container(icons::view::<Message>(item.icon(), icon_color))
|
||
.width(indicator_width)
|
||
.height(indicator_height)
|
||
.align_x(Alignment::Center)
|
||
.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, indicator_height),
|
||
..Border::default()
|
||
},
|
||
..container::Style::default()
|
||
});
|
||
|
||
let item_height = nav_item_height(ui, indicator_height);
|
||
// 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作,
|
||
// 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。
|
||
let stack = column![
|
||
pill,
|
||
label(ui, TypeRole::LabelMedium, false, item.label(), label_color),
|
||
]
|
||
.spacing(ui.space(Space::X1))
|
||
.width(nav_rail_width(ui.metrics()))
|
||
.align_x(Alignment::Center)
|
||
.height(item_height);
|
||
|
||
button(stack)
|
||
.width(nav_rail_width(ui.metrics()))
|
||
.height(item_height)
|
||
.padding(0)
|
||
.style(move |_theme: &IcedTheme, status| {
|
||
let background = match overridden(forced, status) {
|
||
button::Status::Hovered if !selected => Some(Background::Color(ui.state_over(
|
||
StateLayer::Hover,
|
||
Role::OnSurface,
|
||
Role::Surface,
|
||
))),
|
||
button::Status::Pressed if !selected => Some(Background::Color(ui.state_over(
|
||
StateLayer::Pressed,
|
||
Role::OnSurface,
|
||
Role::Surface,
|
||
))),
|
||
_ => None,
|
||
};
|
||
|
||
button::Style {
|
||
background,
|
||
text_color: label_color,
|
||
border: Border::default(),
|
||
shadow: Shadow::default(),
|
||
snap: true,
|
||
}
|
||
})
|
||
.on_press(Message::Select(item))
|
||
.into()
|
||
}
|
||
|
||
/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。
|
||
///
|
||
/// M69(`141e9b1`)把 `LayoutMetrics` 的 nav 字段做了语义纠正:
|
||
///
|
||
/// | 旧 | 新 |
|
||
/// |---|---|
|
||
/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,名字从「展开」改成「轨」) |
|
||
/// | `nav_collapsed_width = 48` | 删除(48 连 56 宽的选中指示器都放不下) |
|
||
/// | —(缺) | `nav_drawer_width = 240`(D33 的展开态抽屉,本 crate 未用) |
|
||
///
|
||
/// 三处调用点都走这里:将来令牌再改名,仍然只改这一行。
|
||
fn nav_rail_width(metrics: LayoutMetrics) -> f32 {
|
||
metrics.nav_rail_width
|
||
}
|
||
|
||
/// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死:
|
||
///
|
||
/// - 宽 = [`nav_rail_width`] − 2 × `Space::X4` = 88 − 32 = **56**(轨的左右内边距 16)
|
||
/// - 高 = `control_height − Space::X2` = 40 − 8 = **32**(可见高度上下各让 4)
|
||
pub fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) {
|
||
(
|
||
nav_rail_width(metrics) - Space::X4.dp() * 2.0,
|
||
metrics.control_height - Space::X2.dp(),
|
||
)
|
||
}
|
||
|
||
/// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏,
|
||
/// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。
|
||
pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 {
|
||
indicator_height + ui.space(Space::X1) + ui.style(TypeRole::LabelMedium).line_height_px
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use unlucky_design::color::SchemeFlavor;
|
||
use unlucky_design::typography::Platform;
|
||
|
||
use crate::theme::Families;
|
||
|
||
fn ui() -> Ui {
|
||
Ui::with_platform(
|
||
SchemeFlavor::Light,
|
||
Platform::Windows,
|
||
Families {
|
||
text: "Segoe UI",
|
||
display: "Segoe UI",
|
||
mono: "Consolas",
|
||
},
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn indicator_matches_the_reference_56x32() {
|
||
let (width, height) = indicator_size(LayoutMetrics::DESKTOP);
|
||
assert_eq!((width, height), (56.0, 32.0), "12b 5.4 的参考值");
|
||
}
|
||
|
||
#[test]
|
||
fn nav_item_height_is_indicator_plus_gap_plus_label() {
|
||
let ui = ui();
|
||
let (_, indicator_height) = indicator_size(ui.metrics());
|
||
// 32 + 4 + 16
|
||
assert_eq!(nav_item_height(&ui, indicator_height), 52.0);
|
||
}
|
||
|
||
#[test]
|
||
fn every_nav_item_has_a_label_and_an_icon() {
|
||
assert!((4..=6).contains(&NavItem::ALL.len()));
|
||
for item in NavItem::ALL {
|
||
assert!(!item.label().is_empty(), "{item:?} 没有文案");
|
||
assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标");
|
||
}
|
||
}
|
||
}
|