feat(app-iced): M79 导航家族 —— ListItem / Tabs / NavigationDrawer / AppBar

四件组件 + gallery 状态矩阵 + 渲染层几何断言,全部只落在
`components/navigation/**` 与 `gallery/navigation.rs`(不改 mod.rs / interaction / layout)。

- ListItem(12b §4):一行/两行/三行 44/60/76(A1 56/72/88,登记偏离)、leading/trailing 槽位、
  hover/focus/pressed 只叠状态层(内容色不变)、禁用是修饰器且内容角色取 A1 的 `OnSurface`@38%。
- Tabs(12b §6):Primary 3px 指示条(用 `Stack` 分层让 Fill 条拿到「该 tab 的宽」)、
  Secondary 1px `SurfaceVariant` 分隔线、带图标时容器 64、横向滚动;选中色按变体分(Primary / OnSurface)。
- NavigationDrawer(15 §21 + D33):**同一个容器两个宽度** —— rail 88 ⇄ drawer 240;
  药丸从容器宽派生(56×32 / 216×56,左右各缩 16 / 12,A1 `ItemPadding`);
  分组标题 TitleSmall、组间 1px OutlineVariant;drawer 项高 56(G24)。
- AppBar small(12b §16):页头 56(A1 64,登记偏离)、TitleLarge emphasized + LabelMedium 副标题、
  leading/trailing 槽位、滚动只换 `SurfaceContainer` + L2(A1 无 OnScroll…Height)。
- gallery:`ULL_NAV_PART=list|tabs|drawer|appbar|all` 分块出图;四块矩阵(ListItem 三行高 × 三态、
  Tabs 三个选中位置 + 五态、rail/drawer 同尺度并排、AppBar 静息/滚动)。
- `navigation/probe.rs`:读真截图量像素的 4 条断言(行高 44/60/76、禁用 OnSurface@38%、
  指示条宽 = 标签墨迹 + 2×16、rail 88 / drawer 240 与药丸内缩 16/12、AppBar 56),
  已用两次变异(行高改 56/72/88;drawer 内缩改 16)验证能被打红。

门禁:fmt ✓ · clippy -p unlucky-app-iced --all-targets -D warnings ✓ · test -p unlucky-app-iced 195 passed ✓
This commit is contained in:
AskaEth committed 2026-11-02 11:55:59 +08:00
1 parent 22636668d0
commit 81e34d77da
14 files changed
+3149 -39

No files matched your search

@@ -1,22 +1,351 @@
//! gallery 里的导航类小节(**桩**):后续 mission 只改本文件。
//! gallery 里的导航类小节:四块状态矩阵(**断言在 `navigation::probe`,不在这里**)。
//!
//! ## 为什么再切一层 `ULL_NAV_PART`
//!
//! `ULL_GALLERY_SECTION=navigation` 只解决「别的家族」;本家族自己就有四块矩阵,
//! 竖排起来比 760 还高,一屏只拍得到第一块。所以这里加**块选择器**(契约 §9.2 的同一套规则:
//! 环境变量 + 默认向后兼容 + 未识别值回落并打 stderr):
//!
//! ```text
//! ULL_GALLERY_SECTION=navigation ULL_NAV_PART=list # 只渲染 ListItem 矩阵
//! ULL_NAV_PART=tabs | drawer | appbar | all(默认 all)
//! ```
//!
//! 出图命令(worktree 根目录):
//!
//! ```text
//! ULL_GALLERY_SECTION=navigation ULL_NAV_PART=list ./target/debug/unlucky-launcher-iced.exe \
//! --software-renderer --screen=gallery --screenshot target/m79/list-light.png --screenshot-theme=light
//! ULL_NAV_DIR=target/m79 cargo test -p unlucky-app-iced navigation::probe
//! ```
//!
//! ## 矩阵形状
//!
//! ```text
//! ListItem 行 = 一 / 两 / 三行(44 / 60 / 76) 列 = 选中 / 未选中 / 禁用
//! Tabs 两行 Primary(短文案 / 长文案各选中一次)+ 一行 Secondary + 一行五态
//! Drawer rail 88 与 drawer 240 **同尺度并排**(D33:同一容器两个宽度),各 3 项、第 2 项选中
//! AppBar 静息 / 滚动两态(56 高;滚动换容器色 + L2)
//! ```
//!
//! 「画在取景板(`SurfaceContainerLow`)上」不是装饰:`Surface` 的页头/导航与页面底色同色,
//! 不垫一层板子,截图里量不到边界(probe 就是靠这块板的 bbox 量 88 / 240 的)。
use iced::Element;
use iced::widget::{column, container, row, text};
use iced::{Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use super::super::navigation;
use crate::theme::Ui;
use super::super::interaction::Interaction;
use super::super::navigation::{
app_bar, list_item, navigation, tabs, AppBar, AppBarAction, ListItem, NavGroup, NavItem,
NavLayout, Navigation, Tab, Tabs, TabsVariant,
};
use super::{heading, DemoMessage};
/// 块选择器的环境变量名(语义见模块文档)。
pub const PART_ENV: &str = "ULL_NAV_PART";
/// 本次渲染哪一块(`all` = 四块都渲染,默认)。
fn part() -> String {
let Ok(raw) = std::env::var(PART_ENV) else {
return "all".to_owned();
};
let name = raw.trim().to_ascii_lowercase();
match name.as_str() {
"" | "all" | "list" | "tabs" | "drawer" | "appbar" => name,
_ => {
eprintln!("[gallery] {PART_ENV}={raw:?} 不是已知块(all|list|tabs|drawer|appbar),已回落 all");
"all".to_owned()
}
}
}
/// 演示消息(组件只认泛型,映射在 gallery 侧落地)。
fn pressed() -> DemoMessage {
DemoMessage::Pressed("navigation")
}
fn icon(name: &str) -> IconKind {
match name {
"download" => IconKind::Download,
"logs" => IconKind::Logs,
"settings" => IconKind::Settings,
_ => IconKind::Instances,
}
}
/// 取景板:`SurfaceContainerLow` 底(`Surface` 的页头/导航要靠它才有边界)。
fn plate<'a>(
ui: &'a Ui,
pad: f32,
content: impl Into<Element<'a, DemoMessage>>,
) -> Element<'a, DemoMessage> {
container(content)
.padding(pad)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SurfaceContainerLow))),
..container::Style::default()
})
.into()
}
/// 一行小字图例。
fn note<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, DemoMessage> {
let style = ui.style(TypeRole::BodySmall);
text(value)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant))
.into()
}
/// 导航类家族的小节。
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
iced::widget::column![
heading(
let picked = part();
let mut body = column![heading(
ui,
"navigation · list item / tabs / drawer / app bar",
"12b §4/§6/§16 + 15 §21:ListItem 44/60/76 · Tabs 48 + 指示条 3 · \
Drawer = rail 88 ⇄ drawer 240(同一容器)· AppBar 56 + 滚动换色"
)]
.spacing(ui.space(Space::X8));
if picked == "all" || picked == "list" {
body = body.push(list_part(ui));
}
if picked == "all" || picked == "tabs" {
body = body.push(tabs_part(ui));
}
if picked == "all" || picked == "drawer" {
body = body.push(drawer_part(ui));
}
if picked == "all" || picked == "appbar" {
body = body.push(app_bar_part(ui));
}
body.into()
}
/// 一个列表格(宽 = `nav_drawer_width` 240:够放下 icon + 两行文本,且与 drawer 药丸 216 可区分)。
fn list_cell<'a>(ui: &'a Ui, item: ListItem<'a, DemoMessage>) -> Element<'a, DemoMessage> {
container(list_item(ui, item))
.width(Length::Fixed(ui.metrics().nav_drawer_width))
.into()
}
/// ListItem:三行高 × {选中 / 未选中 / 禁用}。
fn list_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let states = ["selected", "enabled", "disabled"];
let mut grid = column![].spacing(ui.space(Space::X4));
let header = states
.iter()
.map(|name| {
container(note(ui, name))
.width(Length::Fixed(ui.metrics().nav_drawer_width))
.into()
})
.collect::<Vec<Element<'a, DemoMessage>>>();
grid = grid.push(
row![
note(ui, "rows +16 each · 44 / 60 / 76"),
row(header).spacing(ui.space(Space::X2))
]
.spacing(ui.space(Space::X2)),
);
for (index, lines) in ["one line", "two lines", "three lines"].iter().enumerate() {
let mut cells = row![].spacing(ui.space(Space::X2));
for state in states {
// 三列的**内容完全相同**(同样的槽位),只有态不同——否则「选中 vs 未选中」没法逐像素比
let mut item = ListItem::new(lines, pressed()).leading(icon("instances"));
if index >= 1 {
item = item.supporting("3 versions · 1.21.4");
}
if index >= 2 {
item = item.overline("RECENT");
}
item = match state {
"selected" => item.selected().trailing(icon("settings")),
// 禁用是修饰器:这里只清消息,槽位全部保留
"disabled" => item.disabled(),
_ => item.state(Interaction::Enabled),
};
cells = cells.push(list_cell(ui, item));
}
grid = grid.push(cells);
}
column![
note(
ui,
"navigation · list item / tabs / drawer / app bar",
"12b §4/§6/§16 + 15 §21 —— pending(后续 mission 填充本文件)"
"ListItem · 选中 = SecondaryContainer(偏离 D-M79-1:A1 的 ListItem 不换底色)· \
禁用 = OnSurface@38%(A1 ListTokens)"
),
navigation::demo::<DemoMessage>(ui),
grid,
]
.spacing(ui.space(unlucky_design::spacing::Space::X2))
.spacing(ui.space(Space::X2))
.into()
}
/// Tabs:两行 Primary(短 / 长文案各选中一次 → 指示条宽度必须不同)+ Secondary + 五态。
fn tabs_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let primary_row = |selected: usize| {
let mut builder = Tabs::primary();
for (index, label) in ["Logs", "Instance details", "Settings"].iter().enumerate() {
let mut tab = Tab::new(label, pressed());
if index == 2 {
// 6.1:带图标的 Tabs 容器高 64(纯文字 48)
tab = tab.icon(icon("settings"));
}
if index == selected {
tab = tab.selected();
}
builder = builder.tab(tab);
}
tabs(ui, builder)
};
let state_row = {
let mut builder = Tabs::primary();
for (index, state) in [
Interaction::Enabled,
Interaction::Hovered,
Interaction::Focused,
Interaction::Pressed,
Interaction::Disabled,
]
.iter()
.enumerate()
{
let mut tab = Tab::new("Tab", pressed()).state(*state);
if index == 0 {
tab = tab.selected();
}
builder = builder.tab(tab);
}
tabs(ui, builder)
};
let secondary = tabs(
ui,
Tabs::secondary()
.tab(Tab::new("Release", pressed()).selected())
.tab(Tab::new("Snapshot", pressed()))
.tab(Tab::new("Legacy", pressed()).disabled()),
);
// 两式的名字进图例(同时把 `TabsVariant::ALL` 变成可点检的常量,而不是死代码)
let legend = TabsVariant::ALL
.iter()
.map(|variant| note(ui, variant.name()))
.collect::<Vec<_>>();
column![
note(
ui,
"Tabs · Primary 三行 = 选中第 1 / 2 / 3 个(指示条跟着走且宽度跟随该项);Secondary:1px SurfaceVariant"
),
row(legend).spacing(ui.space(Space::X4)),
primary_row(0),
primary_row(1),
primary_row(2),
secondary,
note(ui, "Primary 五态(第 1 项选中;未选项 hover/focus/pressed 提到 OnSurface)"),
state_row,
]
.spacing(ui.space(Space::X2))
.into()
}
/// 导航容器:rail 88 与 drawer 240 **同尺度并排**(D33 的可验收形式)。
fn drawer_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
// 取景板高 288(= Space::X8 × 9):装得下 drawer 的「两个分组 + 三项 + 两次标题」,否则末行被裁
let plate_height = ui.space(Space::X8) * 9.0;
let mut panes = row![].spacing(ui.space(Space::X6));
for layout in NavLayout::ALL {
let builder = match layout {
NavLayout::Rail => Navigation::rail(),
NavLayout::Drawer => Navigation::drawer(),
};
let pane = builder
.group(
NavGroup::titled("LIBRARY")
.item(NavItem::new("Instances", icon("instances"), pressed()).selected())
.item(
NavItem::new("Download", icon("download"), pressed())
.state(Interaction::Hovered),
),
)
.group(
NavGroup::titled("SYSTEM")
.item(NavItem::new("Settings", icon("settings"), pressed()).disabled()),
);
let width = match layout {
NavLayout::Rail => ui.metrics().nav_rail_width,
NavLayout::Drawer => ui.metrics().nav_drawer_width,
};
// 取景板比容器大一圈(pad 8):`Surface` 的导航与页面底色同色,板要露边才量得到
let pad = ui.space(Space::X2);
panes = panes.push(
container(container(navigation(ui, pane)).height(Length::Fixed(plate_height)))
.width(Length::Fixed(width + pad * 2.0))
.height(Length::Fixed(plate_height + pad * 2.0))
.padding(pad)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SurfaceContainerLow))),
..container::Style::default()
}),
);
}
column![
note(
ui,
"D33:同一个容器两个宽度 —— rail(药丸 56×32)|drawer(药丸 216×56,左右各缩 12); 第 2 项 hover、第 3 项禁用"
),
panes,
row![
note(ui, "左→右:"),
note(ui, NavLayout::Rail.name()),
note(ui, NavLayout::Drawer.name()),
]
.spacing(ui.space(Space::X2)),
]
.spacing(ui.space(Space::X2))
.into()
}
/// AppBar small:静息 / 滚动两态(56 高;滚动换容器色 + L2)。
fn app_bar_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
let bar = |scrolled: bool| {
app_bar(
ui,
AppBar::new("Instances")
.subtitle(if scrolled {
"scrolled · SurfaceContainer + L2"
} else {
"resting · Surface + L0"
})
.leading(AppBarAction::inert(icon("instances")))
.trailing(AppBarAction::new(icon("settings"), pressed()))
.scrolled(scrolled),
)
};
column![
note(
ui,
"AppBar small · 56 高(A1 64,偏离 D-M79-2)· 滚动只换容器色与高程,不改高度(A1 无 OnScroll…Height)"
),
plate(ui, ui.space(Space::X2), column![bar(false), bar(true)].spacing(ui.space(Space::X2))),
]
.spacing(ui.space(Space::X2))
.into()
}
@@ -0,0 +1,341 @@
//! AppBar(small)(`research/12b` §16):页头 56、标题 + 副标题、leading / trailing 槽位、滚动换色。
//!
//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR)
//!
//! | # | 检查项 | 参考值 | 本项目 |
//! |---|---|---|---|
//! | 16.1 | 页头高 | `AppBarSmallTokens.ContainerHeight` = **64** | **56** = `LM.page_header_height`(登记偏离 −8) |
//! | 16.3 | 容器 | `AppBarTokens.ContainerColor = Surface` + L0;**滚动后** `OnScrollContainerColor = SurfaceContainer` + `OnScrollContainerElevation`(L2) | 同 |
//! | 16.4 | 字级 | `TitleFont = TitleLarge`(emphasized w500)· `SubtitleFont = LabelMedium` + `SubtitleColor = OnSurfaceVariant` | 同 |
//! | 16.5 | 图标 | `IconSize = 24`;leading `OnSurface`、trailing `OnSurfaceVariant` | 同 |
//! | 16.6 | 左右内边距 | `LeadingSpace = 4` / `TrailingSpace = 4`(+ IconButton 自带 40 宽 → 视觉起手 20) | 同 |
//!
//! ## 「滚动时的高度变化」——**规范上不存在**(不是我们没做)
//!
//! A1 的 `AppBarTokens` 全字段实读(`javap -p -c androidx.compose.material3.tokens.AppBarTokens`):
//!
//! ```text
//! AvatarSize 32 · ContainerColor Surface · ContainerElevation · ContainerShape CornerNone
//! IconButtonSpace · IconSize 24 · LeadingIconColor · LeadingSpace 4
//! OnScrollContainerColor SurfaceContainer · OnScrollContainerElevation ← 滚动只改这两项
//! SubtitleColor/SubtitleFont · TitleColor/TitleFont · TrailingIconColor · TrailingSpace 4
//! ```
//!
//! **整个 tokens 包里 `OnScroll*` 只有两个字段**(颜色与高程,见 `grep` 全量核对的报告),
//! 没有任何 `OnScroll…Height`;高度只在**尺寸档**(`AppBarSmallTokens.ContainerHeight = 64`)里,
//! 而那是 `small` / `medium` / `large` 三个**不同组件**的分档,不是滚动驱动的形变。
//! 所以滚动时**高度不变**、只换容器色 + 升到 L2 阴影——这是规范,不是偏离。
//!
//! 我们与规范的两处差异各登记一条:
//!
//! - **D-M79-2** 高度 56 而不是 64(项目骨架口径 `LM.page_header_height`,已有页面按 56 排版);
//! - 不做 `Medium` / `Large` 的**折叠(collapsing)**形态——那是另外两个组件(`AppBarMediumTokens`
//! / `AppBarLargeTokens`),本 mission 的 §16 只要求 small。
use iced::widget::text::LineHeight;
use iced::widget::{button, container, text};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow, Vector,
};
use unlucky_design::color::Role;
use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::AppBarGeometry;
/// 页头里的一个图标动作。
#[derive(Debug, Clone)]
pub struct AppBarAction<M> {
icon: IconKind,
on_press: Option<M>,
}
impl<M> AppBarAction<M> {
/// 一个可点的图标动作。
pub fn new(icon: IconKind, on_press: M) -> Self {
Self {
icon,
on_press: Some(on_press),
}
}
/// 一个装饰性图标(无消息)。
pub fn inert(icon: IconKind) -> Self {
Self {
icon,
on_press: None,
}
}
}
/// 一条 AppBar small。
#[derive(Debug, Clone)]
pub struct AppBar<'a, M> {
title: &'a str,
subtitle: Option<&'a str>,
leading: Option<AppBarAction<M>>,
trailing: Vec<AppBarAction<M>>,
scrolled: bool,
}
impl<'a, M> AppBar<'a, M> {
/// 标题(`TitleLarge`,emphasized)。
pub fn new(title: &'a str) -> Self {
Self {
title,
subtitle: None,
leading: None,
trailing: Vec::new(),
scrolled: false,
}
}
/// 副标题(`LabelMedium` + `OnSurfaceVariant`)。
pub fn subtitle(mut self, subtitle: &'a str) -> Self {
self.subtitle = Some(subtitle);
self
}
/// 前置动作(导航返回 / 汉堡;`OnSurface`)。
pub fn leading(mut self, action: AppBarAction<M>) -> Self {
self.leading = Some(action);
self
}
/// 追加一个后置动作(`OnSurfaceVariant`)。
pub fn trailing(mut self, action: AppBarAction<M>) -> Self {
self.trailing.push(action);
self
}
/// 滚动状态:`true` = 容器色 `SurfaceContainer` + L2 阴影(A1 `OnScroll*`)。
pub fn scrolled(mut self, scrolled: bool) -> Self {
self.scrolled = scrolled;
self
}
}
/// 一个图标动作按钮(40×40 命中区、图标 24 经 `layout::fixed`——Q9)。
fn action<'a, M: Clone + 'a>(
ui: &'a Ui,
action: AppBarAction<M>,
geometry: AppBarGeometry,
color: Color,
) -> Element<'a, M> {
let hovered =
interaction::overlay_color(ui, Interaction::Hovered, Role::OnSurface, Role::Surface);
let icon = layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(action.icon, color),
);
let hit = ui.metrics().control_height;
let button = button(
container(icon)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center),
)
.width(Length::Fixed(hit))
.height(Length::Fixed(hit))
.padding(0)
.style(move |_theme, status| button::Style {
background: some_if_hovered(status, hovered),
text_color: color,
border: Border {
// IconButton 的圆角是 full:按命中区边长折算(`radius_dp` 不接受 Full)
radius: ui.radius(ShapeScale::Full, hit),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
});
match action.on_press {
Some(message) => button.on_press(message).into(),
None => button.into(),
}
}
/// hover 时给背景(`None` 的其他态不叠状态层)。
fn some_if_hovered(status: button::Status, hovered: Option<Color>) -> Option<Background> {
match status {
button::Status::Hovered => hovered.map(Background::Color),
_ => None,
}
}
/// AppBar small(页头 56 / Surface / 滚动换色)。
pub fn app_bar<'a, M: Clone + 'a>(ui: &'a Ui, bar: AppBar<'a, M>) -> Element<'a, M> {
let geometry = AppBarGeometry::of(ui);
let title_style = ui.emphasized(TypeRole::TitleLarge);
let mut texts = iced::widget::Column::new().spacing(0);
texts = texts.push(
text(bar.title)
.size(title_style.size_px)
.line_height(LineHeight::Absolute(Pixels(title_style.line_height_px)))
.font(ui.font_of(title_style))
.color(ui.color(Role::OnSurface)),
);
if let Some(subtitle) = bar.subtitle {
let style = ui.style(TypeRole::LabelMedium);
texts = texts.push(
text(subtitle)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant)),
);
}
let mut row = iced::widget::Row::new()
.align_y(Alignment::Center)
.spacing(ui.space(unlucky_design::spacing::Space::X2));
if let Some(leading) = bar.leading {
// 16.5:leading 图标 `OnSurface`
row = row.push(action(ui, leading, geometry, ui.color(Role::OnSurface)));
}
row = row.push(texts);
// 标题与后置动作之间必须有 Fill:否则标题会被推到右边或右侧动作贴住标题(M77 Q8 同款)
row = row.push(iced::widget::space::horizontal());
for action_item in bar.trailing {
// 16.5:trailing 图标 `OnSurfaceVariant`
row = row.push(action(
ui,
action_item,
geometry,
ui.color(Role::OnSurfaceVariant),
));
}
let background = if bar.scrolled {
ui.color(Role::SurfaceContainer)
} else {
ui.color(Role::Surface)
};
let elevation = scroll_elevation(bar.scrolled);
let shadow = if matches!(elevation, Elevation::Level0) {
Shadow::default()
} else {
let spec = elevation.shadow();
Shadow {
color: Color {
a: spec.alpha,
..ui.color(Role::Shadow)
},
offset: Vector::new(0.0, spec.offset_y_px),
blur_radius: spec.blur_px,
}
};
container(row)
.width(Length::Fill)
.height(Length::Fixed(geometry.height))
.padding(Padding {
top: 0.0,
right: geometry.edge,
bottom: 0.0,
left: geometry.edge,
})
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: Border {
// 16.3:ContainerShape = CornerNone
radius: ui.radius_dp(ShapeScale::None),
..Border::default()
},
shadow,
..container::Style::default()
})
.into()
}
/// 滚动态的高程档(A1 `AppBarTokens.OnScrollContainerElevation`;静息 L0 = `ContainerElevation`)。
fn scroll_elevation(scrolled: bool) -> Elevation {
if scrolled {
Elevation::Level2
} else {
Elevation::Level0
}
}
#[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",
},
)
}
/// 16.1:页头高 = `LM.page_header_height`(56),并**钉死**它与 A1 的 64 不同。
#[test]
fn height_is_pinned_to_the_page_header_token() {
let ui = ui();
let geometry = AppBarGeometry::of(&ui);
assert_eq!(geometry.height, ui.metrics().page_header_height);
assert_eq!(geometry.height, 56.0);
assert_ne!(
geometry.height, 64.0,
"64 是 A1 AppBarSmallTokens.ContainerHeight;本项目按骨架令牌取 56(D-M79-2)"
);
}
/// 16.5 / 16.6:图标 24、左右内边距 4、头像 32。
#[test]
fn icon_and_edges_come_from_tokens() {
let geometry = AppBarGeometry::of(&ui());
assert_eq!(geometry.icon, 24.0);
assert_eq!(geometry.edge, 4.0);
assert_eq!(geometry.avatar, 32.0);
}
/// 「滚动只换色 + 升 L2」:这条把 `OnScroll*` 的两个字段钉住(高度**不在**这里面)。
#[test]
fn scroll_switches_color_and_elevation_only() {
let ui = ui();
assert_eq!(scroll_elevation(false), Elevation::Level0);
assert_eq!(scroll_elevation(true), Elevation::Level2);
// 高度与滚动无关:同一个几何(这条是「滚动不改高度」的可执行声明)
assert_eq!(
AppBarGeometry::of(&ui).height,
ui.metrics().page_header_height,
"高度只由骨架令牌决定,没有 OnScroll…Height"
);
}
/// 树能建出来(leading + 副标题 + 两个 trailing + 滚动态)。
#[test]
fn view_builds_a_tree() {
let ui = ui();
let _: Element<'_, ()> = app_bar(
&ui,
AppBar::new("Instances")
.subtitle("4 instances")
.leading(AppBarAction::new(IconKind::Instances, ()))
.trailing(AppBarAction::new(IconKind::Settings, ()))
.trailing(AppBarAction::inert(IconKind::Logs))
.scrolled(true),
);
}
}
@@ -0,0 +1,140 @@
//! NavigationDrawer / NavigationRail(`research/15` §21 + 12b §5):**同一个容器的两个宽度**(D33)。
//!
//! 本文件只装「容器」:宽度(88 ⇄ 240)、`CornerLargeEnd` 圆角、分组标题、组间分隔线。
//! 一项长什么样(`NavItem` / `NavGroup` / 颜色求值 / 药丸排布)在 `drawer/item.rs`。
//!
//! ## 参考值与 D33(详见 `geometry.rs` 的表)
//!
//! ```text
//! 折叠 = Rail 88 = LayoutMetrics::nav_rail_width 项高 52 = 药丸 32 + 4 + 标签 16
//! 展开 = Drawer 240 = LayoutMetrics::nav_drawer_width 项高 56 = A1 ActiveIndicatorHeight(G24)
//! 药丸:rail 88 − 2×16 = 56(M68 的 56×32);drawer 240 − 2×12 = 216(= A1 的 360 − 2×12)
//! ```
//!
//! 两者共用 [`navigation`] 一个入口、一套颜色角色、一套 full 圆角药丸;切换时圆角语言不变,
//! 一屏导航容器数恒为 1(§21.9)。**不是两套组件**——D33 的可验收形式就是「同一函数两个宽度」。
//!
//! ## Q8 的现场
//!
//! M68 的药丸写 56 宽后用 `center_x(Length::Fill)` 居中,容器尺寸被 Fill 覆盖成 88 宽(图上横贯全轨)。
//! 这里药丸宽由 [`NavPaneGeometry::pill_width`] 给出,居中一律交给父级 `align_x`。
//!
//! ## 分组标题(只 drawer 侧,§21.8)
//!
//! `TitleSmall` + `OnSurfaceVariant`,**不可点**;组与组之间 1px `OutlineVariant`
//! (复用 [`crate::app::components::divider`]),不用背景色块分区。
use iced::widget::container;
use iced::{Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::divider;
use super::geometry::{NavLayout, NavPaneGeometry};
mod item;
use item::{line, nav_row};
pub use item::{NavGroup, NavItem};
/// 导航容器(D33:`layout` 决定 88 还是 240,其余全同)。
#[derive(Debug, Clone)]
pub struct Navigation<'a, M> {
layout: NavLayout,
groups: Vec<NavGroup<'a, M>>,
}
impl<'a, M> Navigation<'a, M> {
/// 折叠态:rail(88)。
pub fn rail() -> Self {
Self {
layout: NavLayout::Rail,
groups: Vec::new(),
}
}
/// 展开态:标准常驻 drawer(240)。
pub fn drawer() -> Self {
Self {
layout: NavLayout::Drawer,
groups: Vec::new(),
}
}
/// 追加分组(组间自动画 1px `OutlineVariant`)。
pub fn group(mut self, group: NavGroup<'a, M>) -> Self {
self.groups.push(group);
self
}
}
/// 导航容器(rail ⇄ drawer 的唯一入口)。
pub fn navigation<'a, M: Clone + 'a>(ui: &'a Ui, nav: Navigation<'a, M>) -> Element<'a, M> {
let geometry = NavPaneGeometry::of(ui, nav.layout);
let gap = match nav.layout {
NavLayout::Rail => ui.metrics().nav_item_gap,
// drawer 的项是紧贴的(56 已经含了内部留白,§21.4)
NavLayout::Drawer => 0.0,
};
let mut body = iced::widget::Column::new().spacing(gap).width(Length::Fill);
for (index, group) in nav.groups.into_iter().enumerate() {
// 组间分隔线只属于 drawer:rail 88 宽里一条通栏线只会切碎药丸的节奏(§21.8 的分区是 drawer 语义)
if index > 0 && matches!(nav.layout, NavLayout::Drawer) {
body = body.push(container(divider::full::<M>(ui)).padding(Padding {
top: ui.space(unlucky_design::spacing::Space::X2),
bottom: ui.space(unlucky_design::spacing::Space::X2),
left: 0.0,
right: 0.0,
}));
}
// 标题只在 drawer 侧出现(rail 88 宽放不下文字,§21.8 的分区是 drawer 语义)
if let (NavLayout::Drawer, Some(title)) = (nav.layout, group.title) {
body = body.push(
container(line::<M>(
ui,
title,
TypeRole::TitleSmall,
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill)
.padding(Padding {
top: ui.space(unlucky_design::spacing::Space::X2),
right: geometry.pad_start,
bottom: ui.space(unlucky_design::spacing::Space::X1),
left: geometry.pill_inset + geometry.pad_start,
}),
);
}
for item in group.items {
body = body.push(nav_row(ui, item, geometry));
}
}
container(body)
.width(Length::Fixed(geometry.width))
.height(Length::Fill)
// 轨顶留白走 padding(不是 spacer 元素:会被 column 的 spacing 再加一档,M68 P3-5)
.padding(Padding {
top: ui.space(unlucky_design::spacing::Space::X6),
right: 0.0,
bottom: 0.0,
left: 0.0,
})
.style(move |_theme| container::Style {
// §21.3 标准(常驻)= Surface + L0;形状 CornerLargeEnd(内侧两角 16)
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
border: Border {
radius: geometry.radius,
..Border::default()
},
..container::Style::default()
})
.into()
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,283 @@
//! 导航项的公共类型与一行渲染(`drawer.rs` 的私有子模块:单文件 ≤400 行,AGENTS.md §3-1)。
//!
//! 拆出去的是「一项长什么样」(`NavItem` / `NavGroup` + 颜色求值 + 一行布局),
//! `drawer.rs` 只留「容器长什么样」(宽度、圆角、分组标题、分隔线)。
use iced::widget::text::LineHeight;
use iced::widget::{button, container, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::super::interaction::{self, Interaction};
use super::super::super::layout;
use super::super::geometry::{NavLayout, NavPaneGeometry};
/// 一条导航项。
#[derive(Debug, Clone)]
pub struct NavItem<'a, M> {
pub(super) label: &'a str,
pub(super) icon: IconKind,
pub(super) selected: bool,
pub(super) on_press: Option<M>,
pub(super) state: Option<Interaction>,
}
impl<'a, M> NavItem<'a, M> {
/// 一个可点的导航项(图标 + 文案)。
pub fn new(label: &'a str, icon: IconKind, on_press: M) -> Self {
Self {
label,
icon,
selected: false,
on_press: Some(on_press),
state: None,
}
}
/// 当前项(药丸 + `OnSecondaryContainer`)。
pub fn selected(mut self) -> Self {
self.selected = true;
self
}
/// 强制视觉态(gallery 取证;不动状态机)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 转禁用:**只清消息**(修饰器;图标 / 选中态 / 槽位全部保留)。
pub fn disabled(mut self) -> Self {
self.on_press = None;
self
}
}
/// 一个分组(drawer 侧可带标题;rail 侧标题会被忽略——rail 只有图标)。
#[derive(Debug, Clone)]
pub struct NavGroup<'a, M> {
pub(super) title: Option<&'a str>,
pub(super) items: Vec<NavItem<'a, M>>,
}
impl<'a, M> NavGroup<'a, M> {
/// 无标题分组。
pub fn new() -> Self {
Self {
title: None,
items: Vec::new(),
}
}
/// 带标题分组(§21.8:`TitleSmall` + `OnSurfaceVariant`,不可点)。
pub fn titled(title: &'a str) -> Self {
Self {
title: Some(title),
items: Vec::new(),
}
}
/// 追加一项。
pub fn item(mut self, item: NavItem<'a, M>) -> Self {
self.items.push(item);
self
}
}
impl<'a, M> Default for NavGroup<'a, M> {
fn default() -> Self {
Self::new()
}
}
/// 一项的颜色(按态 + 选中求值)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct ItemPalette {
/// 药丸底色(`None` = 不画药丸 / 不叠状态层)。
pub(super) pill: Option<Color>,
/// 图标色。
pub(super) icon: Color,
/// 标签色。
pub(super) label: Color,
}
pub(super) fn palette(
ui: &Ui,
layout: NavLayout,
state: Interaction,
selected: bool,
) -> ItemPalette {
if state.is_disabled() {
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
return ItemPalette {
pill: None,
icon: ink,
label: ink,
};
}
if selected {
let pill = interaction::overlay_color(
ui,
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)
.unwrap_or_else(|| ui.color(Role::SecondaryContainer));
return ItemPalette {
pill: Some(pill),
icon: ui.color(Role::OnSecondaryContainer),
// rail 的选中标签是 `Secondary`(12b 5.6);drawer 是 `OnSecondaryContainer`(§21.7)
label: match layout {
NavLayout::Rail => ui.color(Role::Secondary),
NavLayout::Drawer => ui.color(Role::OnSecondaryContainer),
},
};
}
// 未选中:静息 `OnSurfaceVariant`,hover/focus/pressed 提到 `OnSurface`
let ink = match state {
Interaction::Enabled => ui.color(Role::OnSurfaceVariant),
_ => ui.color(Role::OnSurface),
};
ItemPalette {
pill: interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface),
icon: ink,
label: ink,
}
}
/// 一块文字(显式行高,让项高与行高对得上)。
pub(super) fn line<'a, M: 'a>(
ui: &'a Ui,
value: &'a str,
role: TypeRole,
color: Color,
) -> Element<'a, M> {
let style = ui.style(role);
text(value)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.into()
}
/// 一项(rail:图标在上、标签在下;drawer:图标在左、标签在右)。
pub(super) fn nav_row<'a, M: Clone + 'a>(
ui: &'a Ui,
item: NavItem<'a, M>,
geometry: NavPaneGeometry,
) -> Element<'a, M> {
let forced = item.state.or(if item.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
let state = forced.unwrap_or(Interaction::Enabled);
let colors = palette(ui, geometry.layout, state, item.selected);
let radius = iced::border::Radius::new(geometry.pill_height / 2.0);
let icon = layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(item.icon, colors.icon),
);
// 药丸本体:宽高都来自几何(rail 56×32 / drawer 216×56),居中交给父级 align_x
let pill = move |content: Element<'a, M>| -> Element<'a, M> {
let fill = colors.pill;
container(content)
.width(Length::Fixed(geometry.pill_width))
.height(Length::Fixed(geometry.pill_height))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: fill.map(Background::Color),
border: Border {
radius,
..Border::default()
},
..container::Style::default()
})
.into()
};
let body: Element<'a, M> = match geometry.layout {
NavLayout::Rail => {
// rail:药丸(56×32,图标居中)+ 4 + 标签(LabelMedium)
let mut stack = iced::widget::Column::new()
.spacing(geometry.gap)
.width(Length::Fixed(geometry.width))
.align_x(Alignment::Center);
stack = stack.push(pill(icon));
stack = stack.push(line::<M>(
ui,
item.label,
TypeRole::LabelMedium,
colors.label,
));
stack.into()
}
NavLayout::Drawer => {
// drawer:药丸就是一整行(216×56),内容从 pad_start 16 起、图标↔label 12
let row = iced::widget::Row::new()
.align_y(Alignment::Center)
.spacing(geometry.gap)
.padding(Padding {
top: 0.0,
right: geometry.pad_end,
bottom: 0.0,
left: geometry.pad_start,
});
pill(Element::from(
row.push(icon)
.push(line::<M>(
ui,
item.label,
TypeRole::LabelLarge,
colors.label,
))
.push(iced::widget::space::horizontal()),
))
}
};
let height = geometry.item_height;
let clickable = button(body)
.width(Length::Fill)
.height(Length::Fixed(height))
.padding(0)
.style(move |_theme, _status| button::Style {
// 底色由药丸给(rail 的 hover 层也画在药丸上,与 drawer 同口径)
background: None,
text_color: colors.label,
border: Border::default(),
shadow: Shadow::default(),
snap: true,
});
let element: Element<'a, M> = match item.on_press {
Some(message) => clickable.on_press(message).into(),
None => clickable.into(),
};
// drawer 的项外距 12(`ItemPadding`):药丸两侧由内缩给出,所以整行再包一层水平内边距
match geometry.layout {
NavLayout::Drawer => container(element)
.width(Length::Fill)
.padding(Padding {
top: 0.0,
right: geometry.pill_inset,
bottom: 0.0,
left: geometry.pill_inset,
})
.into(),
NavLayout::Rail => element,
}
}
@@ -0,0 +1,115 @@
//! `drawer` 的单元测试(单文件 ≤400 行,AGENTS.md §3-1 拆出来)。
use super::super::super::interaction::{self, Interaction};
use super::item::palette;
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::icons::IconKind;
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",
},
)
}
/// §21.7:选中项图标/标签都是 `OnSecondaryContainer`,药丸 `SecondaryContainer`。
#[test]
fn drawer_selected_uses_secondary_container() {
let ui = ui();
let palette = palette(&ui, NavLayout::Drawer, Interaction::Enabled, true);
assert_eq!(palette.pill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(palette.icon, ui.color(Role::OnSecondaryContainer));
assert_eq!(palette.label, ui.color(Role::OnSecondaryContainer));
}
/// 12b §5.6:**rail** 的选中标签是 `Secondary`(与 drawer 不同,别串线)。
#[test]
fn rail_selected_label_is_secondary() {
let ui = ui();
let rail = palette(&ui, NavLayout::Rail, Interaction::Enabled, true);
let drawer = palette(&ui, NavLayout::Drawer, Interaction::Enabled, true);
assert_eq!(rail.label, ui.color(Role::Secondary));
assert_ne!(rail.label, drawer.label, "两条线的选中标签角色不同");
// 但图标与药丸相同(同一套语言)
assert_eq!(rail.icon, drawer.icon);
assert_eq!(rail.pill, drawer.pill);
}
/// 未选中:静息 `OnSurfaceVariant`;hover/focus/pressed 提到 `OnSurface` 且叠状态层。
#[test]
fn inactive_lifts_to_on_surface() {
let ui = ui();
let resting = palette(&ui, NavLayout::Drawer, Interaction::Enabled, false);
assert_eq!(resting.label, ui.color(Role::OnSurfaceVariant));
assert_eq!(resting.pill, None);
for state in [
Interaction::Hovered,
Interaction::Focused,
Interaction::Pressed,
] {
let active = palette(&ui, NavLayout::Drawer, state, false);
assert_eq!(active.label, ui.color(Role::OnSurface), "{state:?}");
assert_eq!(active.icon, ui.color(Role::OnSurface), "{state:?}");
assert!(active.pill.is_some(), "{state:?} 必须叠状态层");
}
}
/// 禁用:`OnSurface`@38%,不画药丸,不叠状态层。
#[test]
fn disabled_draws_no_pill() {
let ui = ui();
let palette = palette(&ui, NavLayout::Drawer, Interaction::Disabled, true);
assert_eq!(
palette.icon,
interaction::disabled_content_over_role(&ui, Role::OnSurface, false)
);
assert_eq!(palette.label, palette.icon);
assert_eq!(palette.pill, None);
}
/// 禁用是**修饰器**:只清消息,图标 / 选中态全部保留。
#[test]
fn disabled_only_clears_the_message() {
let item = NavItem::new("Settings", IconKind::Settings, ())
.selected()
.disabled();
assert!(item.on_press.is_none());
assert!(item.selected);
assert_eq!(item.icon, IconKind::Settings);
assert_eq!(item.label, "Settings");
}
/// D33:两种宽度都能建出树(同一个 `navigation` 入口)。
#[test]
fn both_layouts_build_a_tree() {
let ui = ui();
for layout in NavLayout::ALL {
let base = match layout {
NavLayout::Rail => Navigation::rail(),
NavLayout::Drawer => Navigation::drawer(),
};
let _: Element<'_, ()> = navigation(
&ui,
base.group(
NavGroup::titled("LIBRARY")
.item(NavItem::new("Instances", IconKind::Instances, ()).selected())
.item(NavItem::new("Download", IconKind::Download, ())),
)
.group(NavGroup::titled("SYSTEM").item(NavItem::new(
"Settings",
IconKind::Settings,
(),
))),
);
}
}
@@ -0,0 +1,330 @@
//! 导航类家族的**几何**:能取令牌的取令牌,取不到的在此登记 G 编号(不散落在组件里)。
//!
//! ## 参考值 → 落点(`[源]` = A1,`material3-android:1.4.0`;本轮逐条用 `javap` 读本机 AAR 复核)
//!
//! ```text
//! javap -p -c androidx.compose.material3.tokens.ListTokens → 56/72/88、16/16、图标 24
//! javap -p -c androidx.compose.material3.tokens.NavigationDrawerTokens
//! → ActiveIndicatorHeight 56、ActiveIndicatorWidth 336、ContainerWidth 360、CornerLargeEnd
//! javap -p -c androidx.compose.material3.NavigationDrawerItemDefaults
//! → ItemPadding = 12(bipush 12 → PaddingValues(12f, 0f))
//! javap -p -c androidx.compose.material3.tokens.PrimaryNavigationTabTokens
//! → ContainerHeight 48、IconAndLabelTextContainerHeight 64、ActiveIndicatorHeight 3、图标 24
//! javap -p -c androidx.compose.material3.tokens.SecondaryNavigationTabTokens → ContainerHeight 48
//! javap -p -c androidx.compose.material3.tokens.AppBarSmallTokens → ContainerHeight 64
//! ```
//!
//! | # | 量 | 参考值(A1) | 本项目 | 来源 | 落点 |
//! |---|---|---|---|---|---|
//! | 4.1 | ListItem 一行高 | **56** | **44** = `LM.row_height`(登记偏离 −12) | `[源]` `ListItemOneLineContainerHeight` | [`ListItemGeometry::heights`] |
//! | 4.2 | ListItem 两/三行高 | **72 / 88** | **60 / 76**(每多一行 +16,与官方同 delta) | `[源]` | 同上 |
//! | 4.3 | ListItem 左右内边距 | **16 / 16** | 同 | `[源]` `ListItemLeading/TrailingSpace` | `pad_start` / `pad_end` |
//! | 4.4 | ListItem leading 图标 | **24** | 同 | `[源]` `ListItemLeadingIconSize` | `icon`(`IconSize::Toolbar`) |
//! | 4.4b | 图标 ↔ 文本间距 | 无 token | **16** | `[估]`(Compose 由 ListItem 内部排版决定) | `gap`(`Space::X4`) |
//! | 6.1 | Tabs 容器高 | **48**(带图标 **64**) | 同 | `[源]` | [`TABS_HEIGHT`](**G22**:`LM` 无此档) |
//! | 6.2 | 指示条 | 高 **3**、圆角 **3**、`Primary` | 同 | `[源]` `ActiveIndicatorHeight` + `RoundedCornerShape(3dp)` | [`TABS_INDICATOR`](**G23**) |
//! | 6.6 | Secondary 分隔线 | **1dp `SurfaceVariant`** | 同 | `[源]` `DividerHeight` / `DividerColor` | [`TabsGeometry::secondary_bar`] |
//! | 21.4 | drawer 项高 | **56**(`heightIn(min = ActiveIndicatorHeight)`) | **56**(`LM.nav_item_height` 是 44,登记偏离 +12) | `[源]` | [`DRAWER_ITEM_HEIGHT`](**G24**) |
//! | 21.5 | drawer 指示器 | 宽 = 容器宽 − **24**(360 → 336);高 = 项高;圆角 full | 240 − 24 = **216** | `[源]` `ItemPadding = 12` | [`NavPaneGeometry::pill_width`] |
//! | 21.6 | drawer 项内 | 项外 **12**;内 start **16** / end **24**;图标 24;图标↔label **12**;label `LabelLarge` | 同 | `[源]` `ItemPadding` + `navigation/15 §21.6` | [`ComponentMetrics::NAVIGATION_DRAWER`] |
//! | 21.2 | drawer 形状 | **`CornerLargeEnd`**(只有靠内容那侧两角 16,贴边侧直角) | 同(右两角 16) | `[源]` `ContainerShape` | [`NavPaneGeometry::radius`] |
//! | 16.1 | AppBar small 高 | **64** | **56** = `LM.page_header_height`(登记偏离 −8) | `[源]` `AppBarSmallTokens.ContainerHeight` | [`AppBarGeometry::height`] |
//! | 16.5/16.6 | AppBar 图标 / 左右内边距 | **24** / **4** | 同 | `[源]` `AppBarTokens.IconSize/LeadingSpace/TrailingSpace` | [`AppBarGeometry`] |
//!
//! **G 编号**(续 `research/15` §25 的 G13;G16~G20 已被 selection/inputs 用掉):
//!
//! - **G22** Tabs 容器高 48 / 带图标 64:`LayoutMetrics` 无此档,收在本文件的 [`TABS_HEIGHT`];
//! - **G23** Tabs 指示条高 3 / 最小项宽 90:同上,收在本文件 [`TABS_INDICATOR`],
//! 而**最小项宽 90(`TabMinWidth`)本轮不施加**——iced 无 min-width,tab 按内容 hug,
//! 指示条因此严格等于「该 tab 的内容宽 + 2×16」,见 [`TabsGeometry::pad`] 的说明;
//! - **G24** drawer 项高 56:令牌 `LM.nav_item_height = 44` 是**列表行**的密集档,drawer 项按 A1 取 56。
//!
//! ## 两个「同一个容器两个宽度」(D33)
//!
//! [`NavPaneGeometry::of`] 是**唯一**一处同时知道 88 与 240 的地方:宽 → 药丸宽、项高、内边距
//! 全部由 `layout` 派生。Q8 的历史事故(药丸 56×32 被 `center_x(Fill)` 撑成 88×32)就出在
//! 「药丸宽度不是从容器宽派生」——这里把派生写成函数,组件层拿不到手写数字。
use iced::border::Radius;
use unlucky_design::component_map::{Component, ComponentMetrics};
use unlucky_design::icons::IconSize;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use crate::theme::Ui;
/// Tabs 容器高(A1 `PrimaryNavigationTabTokens.ContainerHeight`,**G22**)。
pub const TABS_HEIGHT: f32 = 48.0;
/// 带图标的 Tabs 容器高(A1 `IconAndLabelTextContainerHeight`,**G22**;本轮只做文字档)。
pub const TABS_ICON_HEIGHT: f32 = 64.0;
/// Tabs 指示条高(A1 `PrimaryNavigationTabTokens.ActiveIndicatorHeight`,**G23**)。
pub const TABS_INDICATOR: f32 = 3.0;
/// drawer 项高(A1 `NavigationDrawerItemDefaults` 的 `heightIn(min = ActiveIndicatorHeight)`,**G24**)。
pub const DRAWER_ITEM_HEIGHT: f32 = 56.0;
/// 导航容器的两种宽度(D33:**同一个容器**的折叠 / 展开,不是两套组件)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NavLayout {
/// 折叠:宽 **88**(`LayoutMetrics::nav_rail_width`),项内「药丸在上、标签在下」。
Rail,
/// 展开:宽 **240**(`LayoutMetrics::nav_drawer_width`),项内「图标在左、标签在右」。
Drawer,
}
impl NavLayout {
/// 两种宽度(gallery)与测试用。
pub const ALL: [NavLayout; 2] = [NavLayout::Rail, NavLayout::Drawer];
/// 稳定名字(截图文件名 / 图例用)。
pub const fn name(self) -> &'static str {
match self {
NavLayout::Rail => "rail",
NavLayout::Drawer => "drawer",
}
}
}
/// ListItem 的几何(`research/12b` §4)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ListItemGeometry {
/// 一 / 两 / 三行行高(44 / 60 / 76;A1 是 56 / 72 / 88,本项目登记偏离 −12)。
pub heights: [f32; 3],
/// 内容起始侧内边距(16,A1 `ListItemLeadingSpace`)。
pub pad_start: f32,
/// 内容结束侧内边距(16,A1 `ListItemTrailingSpace`)。
pub pad_end: f32,
/// leading / trailing 图标边长(24,A1 `ListItemLeading/TrailingIconSize`)。
pub icon: f32,
/// 图标 ↔ 文本间距(16,`[估]`)。
pub gap: f32,
}
impl ListItemGeometry {
/// 从令牌求值。
pub fn of(ui: &Ui) -> Self {
let one = ui.metrics().row_height;
let step = ui.space(Space::X4);
Self {
// 每多一行 +16:与官方 56→72→88 的 delta 一致,差的只是基准值(−12)。
heights: [one, one + step, one + step * 2.0],
pad_start: ui.space(Space::X4),
pad_end: ui.space(Space::X4),
icon: IconSize::Toolbar.px(),
gap: ui.space(Space::X4),
}
}
/// `lines` 行的行高(1/2/3 之外按就近夹取,不 panic——调用方传入的是枚举值)。
pub fn height(&self, lines: usize) -> f32 {
self.heights[lines.clamp(1, 3) - 1]
}
/// `lines` 行的文本块高(行高 − 文本块 = 上下各一半的内边距)。
pub fn text_block(&self, ui: &Ui, lines: usize) -> f32 {
let headline = ui.style(typography_headline()).line_height_px;
match lines.clamp(1, 3) {
// 一行:主文本(BodyLarge 24)
1 => headline,
// 两行:主 + 辅助(BodyLarge 24 + BodyMedium 20)
2 => headline + ui.style(typography_supporting()).line_height_px,
// 三行:overline(LabelSmall 16)+ 主 + 辅助
_ => {
ui.style(typography_overline()).line_height_px
+ headline
+ ui.style(typography_supporting()).line_height_px
}
}
}
/// 垂直内边距 = (行高 − 文本块) / 2(一行 44 → 10,两行 60 → 8,三行 76 → 8)。
pub fn pad_y(&self, ui: &Ui, lines: usize) -> f32 {
(self.height(lines) - self.text_block(ui, lines)) / 2.0
}
}
/// ListItem 主文本字级(12b 4.5:`BodyLarge 16/24` `[源] ListTokens.ListItemLabelTextFont`)。
pub const fn typography_headline() -> unlucky_design::typography::TypeRole {
unlucky_design::typography::TypeRole::BodyLarge
}
/// ListItem 辅助文本字级(12b 4.5:`BodyMedium 14/20`)。
pub const fn typography_supporting() -> unlucky_design::typography::TypeRole {
unlucky_design::typography::TypeRole::BodyMedium
}
/// ListItem overline 字级(12b 4.5:`LabelSmall 11/16`)。
pub const fn typography_overline() -> unlucky_design::typography::TypeRole {
unlucky_design::typography::TypeRole::LabelSmall
}
/// Tabs 的几何(`research/12b` §6)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TabsGeometry {
/// 容器高(48,**G22**)。
pub height: f32,
/// 图标边长(24,A1 `Primary/SecondaryNavigationTabTokens.IconSize`)。
pub icon: f32,
/// 标签左右内边距(16,`[估]`:Compose `TabRow` 的 `HorizontalTextPadding`)。
///
/// 这条同时决定「指示条跟随选中项宽度」的**可测形态**:指示条宽 = 标签墨迹宽 + 2×16,
/// 所以两段文案长度不同的 tab,其指示条宽度必然不同(渲染层断言用它)。
pub pad: f32,
/// Primary 指示条高(3,**G23**)。
pub primary_bar: f32,
/// Secondary 分隔线厚度(1 = `LM.divider_thickness`)。
pub secondary_bar: f32,
}
impl TabsGeometry {
/// 从令牌求值。
pub fn of(ui: &Ui) -> Self {
Self {
height: TABS_HEIGHT,
icon: IconSize::Toolbar.px(),
pad: ui.space(Space::X4),
primary_bar: TABS_INDICATOR,
secondary_bar: ui.metrics().divider_thickness,
}
}
/// 指示条 / 分隔线高度:Primary 取 3px,Secondary 取 1px。
pub fn bar(&self, primary: bool) -> f32 {
if primary {
self.primary_bar
} else {
self.secondary_bar
}
}
/// 指示条圆角:`RoundedCornerShape(3dp)` 落在 3px 高的条上,与 `Full`(=1.5 半径)渲染结果相同。
pub fn bar_radius(&self, primary: bool) -> Radius {
Radius::new(self.bar(primary) / 2.0)
}
}
/// 导航容器(rail ⇄ drawer)的几何(`research/15` §21 + D33)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NavPaneGeometry {
/// 哪种宽度(88 / 240)。
pub layout: NavLayout,
/// 容器宽(88 / 240)。
pub width: f32,
/// 项高(rail 52 = 药丸 32 + 4 + 标签 16;drawer 56,**G24**)。
pub item_height: f32,
/// 选中药丸宽(rail 56 = 88 − 2×16;drawer 216 = 240 − 2×12)。
pub pill_width: f32,
/// 选中药丸高(rail 32;drawer 56 = 项高)。
pub pill_height: f32,
/// 药丸离容器左右各缩多少(rail 16;drawer 12 = `ItemPadding`)。
pub pill_inset: f32,
/// 内容起始侧内边距(drawer 16;rail 由药丸居中决定)。
pub pad_start: f32,
/// 内容结束侧内边距(drawer 24)。
pub pad_end: f32,
/// 图标边长(24)。
pub icon: f32,
/// 图标 ↔ 标签间距(rail 是纵向 4;drawer 是横向 12)。
pub gap: f32,
/// 容器圆角(`CornerLargeEnd`:贴边侧直角、靠内容侧 16)。
pub radius: Radius,
}
impl NavPaneGeometry {
/// 从令牌求值:**88 与 240 只在这里分叉**,其余全部由宽度派生。
pub fn of(ui: &Ui, layout: NavLayout) -> Self {
let metrics = ui.metrics();
let item = drawer_metrics();
// CornerLargeEnd:只有靠内容那一侧(右侧)两角 16,贴边侧直角。
let corner = shape_radius();
let radius = Radius {
top_left: 0.0,
top_right: corner,
bottom_right: corner,
bottom_left: 0.0,
};
match layout {
NavLayout::Rail => {
let width = metrics.nav_rail_width;
let pill_inset = ui.space(Space::X4);
let pill_height = metrics.control_height - ui.space(Space::X2);
Self {
layout,
width,
// 52 = 药丸 32 + 4 + 标签行高 16(与 `shell/nav.rs` 的项高同源)
item_height: pill_height
+ ui.space(Space::X1)
+ ui.style(unlucky_design::typography::TypeRole::LabelMedium)
.line_height_px,
pill_width: width - pill_inset * 2.0,
pill_height,
pill_inset,
pad_start: pill_inset,
pad_end: pill_inset,
icon: IconSize::Toolbar.px(),
gap: ui.space(Space::X1),
radius,
}
}
NavLayout::Drawer => {
let width = metrics.nav_drawer_width;
Self {
layout,
width,
item_height: DRAWER_ITEM_HEIGHT,
pill_width: item.indicator_width(width),
pill_height: DRAWER_ITEM_HEIGHT,
pill_inset: item.inset,
pad_start: item.pad_start,
pad_end: item.pad_end,
icon: item.icon,
gap: item.gap,
radius,
}
}
}
}
}
/// 导航容器的组件级微度量(`ComponentMetrics::NAVIGATION_DRAWER`;缺令牌会直接炸,不静默回退)。
fn drawer_metrics() -> ComponentMetrics {
Component::NavigationDrawer
.metrics()
.expect("NavigationDrawer 的 ComponentMetrics 是 G10 的落点")
}
/// 容器圆角(`Component::NavigationDrawer.spec().shape = ShapeScale::Large` → 16)。
fn shape_radius() -> f32 {
let shape = Component::NavigationDrawer.spec().shape;
debug_assert_eq!(shape, ShapeScale::Large, "ContainerShape=CornerLargeEnd");
shape.dp()
}
/// AppBar(small)的几何(`research/12b` §16)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AppBarGeometry {
/// 页头高(56 = `LM.page_header_height`;A1 是 64,登记偏离 −8)。
pub height: f32,
/// 图标边长(24,A1 `AppBarTokens.IconSize`)。
pub icon: f32,
/// 左右内边距(4,A1 `AppBarTokens.Leading/TrailingSpace`)。
pub edge: f32,
/// 头像边长(32,A1 `AppBarTokens.AvatarSize`)。
pub avatar: f32,
}
impl AppBarGeometry {
/// 从令牌求值。
pub fn of(ui: &Ui) -> Self {
Self {
height: ui.metrics().page_header_height,
icon: IconSize::Toolbar.px(),
edge: ui.space(Space::X1),
avatar: IconSize::Empty.px(),
}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,132 @@
//! `geometry` 的单元测试(split 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
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",
},
)
}
/// 4.1 / 4.2:三种行高 44 / 60 / 76(A1 56/72/88 的登记偏离 −12)。
#[test]
fn list_item_heights_are_44_60_76() {
let geometry = ListItemGeometry::of(&ui());
assert_eq!(geometry.heights, [44.0, 60.0, 76.0]);
for lines in 1..=3 {
assert_eq!(geometry.height(lines), geometry.heights[lines - 1]);
}
// 官方 delta 也是每行 +16
assert_eq!(geometry.heights[1] - geometry.heights[0], 16.0);
assert_eq!(geometry.heights[2] - geometry.heights[1], 16.0);
}
/// 行高必须**装得下**文本块(否则文字会被裁):三行文本块 16+24+20 = 60 ≤ 76。
#[test]
fn list_item_rows_fit_their_text_block() {
let ui = ui();
let geometry = ListItemGeometry::of(&ui);
for lines in 1..=3 {
let block = geometry.text_block(&ui, lines);
let pad = geometry.pad_y(&ui, lines);
assert!(pad >= 0.0, "{lines} 行的垂直内边距是负数(行高装不下文本)");
assert!(
(block + pad * 2.0 - geometry.height(lines)).abs() < f32::EPSILON,
"{lines} 行:文本块 {block} + 2×{pad} ≠ 行高 {}",
geometry.height(lines)
);
}
assert_eq!(geometry.text_block(&ui, 1), 24.0, "BodyLarge 24/24");
assert_eq!(geometry.text_block(&ui, 2), 44.0, "24 + BodyMedium 20");
assert_eq!(geometry.text_block(&ui, 3), 60.0, "LabelSmall 16 + 24 + 20");
assert_eq!(geometry.pad_y(&ui, 1), 10.0, "(44 − 24) / 2");
assert_eq!(geometry.pad_y(&ui, 3), 8.0, "(76 − 60) / 2");
}
/// 21.1 / 21.5 / 21.6:drawer 240、药丸 216(左右各缩 12)、项高 56、内边距 16/24。
#[test]
fn drawer_pane_is_240_wide_with_a_216_pill() {
let geometry = NavPaneGeometry::of(&ui(), NavLayout::Drawer);
assert_eq!(geometry.width, 240.0, "D33 的展开宽度");
assert_eq!(geometry.pill_inset, 12.0, "ItemPadding");
assert_eq!(
geometry.pill_width, 216.0,
"240 − 2×12(A1: 360 − 2×12 = 336)"
);
assert_eq!(geometry.item_height, 56.0, "G24:A1 ActiveIndicatorHeight");
assert_eq!(geometry.pill_height, 56.0, "指示器高 = 项高");
assert_eq!((geometry.pad_start, geometry.pad_end), (16.0, 24.0));
assert_eq!(geometry.gap, 12.0, "图标 ↔ label");
assert_eq!(geometry.icon, 24.0);
// 药丸 + 两侧内缩 = 容器宽(这条是「药丸从容器宽派生」的等式,Q8 的回归点)
assert!((geometry.pill_width + geometry.pill_inset * 2.0 - geometry.width).abs() < 0.01);
}
/// D33:rail 与 drawer 是**同一个容器**的两个宽度(88 ⇄ 240),不是两套组件。
#[test]
fn rail_and_drawer_share_one_container() {
let ui = ui();
let rail = NavPaneGeometry::of(&ui, NavLayout::Rail);
let drawer = NavPaneGeometry::of(&ui, NavLayout::Drawer);
assert_eq!(rail.width, 88.0);
assert_eq!(drawer.width, 240.0);
assert!(rail.width < drawer.width);
// 同一个「药丸 = 容器宽 − 2×内缩」公式
for pane in [rail, drawer] {
assert!(
(pane.pill_width + pane.pill_inset * 2.0 - pane.width).abs() < 0.01,
"{:?} 的药丸宽不是从容器宽派生的",
pane.layout
);
// 圆角语言不变:都是 CornerLargeEnd(外侧直角、内侧 16)
assert_eq!((pane.radius.top_left, pane.radius.bottom_left), (0.0, 0.0));
assert_eq!(
(pane.radius.top_right, pane.radius.bottom_right),
(16.0, 16.0)
);
assert_eq!(pane.icon, 24.0);
}
// rail 的药丸是 56×32(M68 已修正的结论),不是 drawer 的 216×56
assert_eq!((rail.pill_width, rail.pill_height), (56.0, 32.0));
assert_eq!((drawer.pill_width, drawer.pill_height), (216.0, 56.0));
}
/// 6.1 / 6.2 / 6.6:Tabs 48 高、指示条 3、Secondary 线 1。
#[test]
fn tabs_geometry_matches_the_reference() {
let ui = ui();
let geometry = TabsGeometry::of(&ui);
assert_eq!(geometry.height, 48.0, "G22");
assert_eq!(geometry.icon, 24.0);
assert_eq!(geometry.pad, 16.0);
assert_eq!(geometry.bar(true), 3.0, "ActiveIndicatorHeight");
assert_eq!(geometry.bar(false), 1.0, "DividerHeight");
// RoundedCornerShape(3dp) 落在 3px 高的条上 = 半径 1.5(与 Full 同渲染)
assert_eq!(geometry.bar_radius(true).top_left, 1.5);
assert_eq!(geometry.bar_radius(false).top_left, 0.5);
}
/// 16.1 / 16.5:AppBar 56(LM)、图标 24、左右 4、头像 32。
#[test]
fn app_bar_geometry_matches_the_skeleton() {
let geometry = AppBarGeometry::of(&ui());
assert_eq!(
geometry.height, 56.0,
"LM.page_header_height(A1 64,登记偏离)"
);
assert_eq!((geometry.icon, geometry.edge), (24.0, 4.0));
assert_eq!(geometry.avatar, 32.0);
}
@@ -0,0 +1,303 @@
//! ListItem(`research/12b` §4):一行 / 两行 / 三行,leading / trailing 槽位,选中态,五态状态层。
//!
//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR 的 `ListTokens` / `ListItemColors`)
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 4.1 | 一行高 | A1 **56** → 本项目 **44**(`LM.row_height`,登记偏离 −12) | [`ListItemGeometry::heights`] |
//! | 4.2 | 两 / 三行高 | A1 **72 / 88** → 本项目 **60 / 76**(每多一行 +16,与官方同 delta) | 同上 |
//! | 4.3 | 左右内边距 | **16 / 16** | `pad_start` / `pad_end` |
//! | 4.4 | leading | 图标 **24** `OnSurfaceVariant` | `IconSize::Toolbar` |
//! | 4.5 | 字级 | 主 `BodyLarge 16/24`、辅助 `BodyMedium 14/20`、overline `LabelSmall 11/16` | `TypeRole` |
//! | 4.7 | 状态 | hover/focus/pressed **文本与图标色不变**,只叠状态层(8/10/10%) | [`interaction`] |
//! | 4.8 | 选中 trailing | 图标 → **`Primary`** | `Role::Primary` |
//!
//! ## 禁用是**修饰器**,不是构造函数(照 `selection/chip.rs` 复审 P2-1 的结论)
//!
//! [`ListItem::disabled`] **只清消息**:图标、选中态、状态层全部保留。早期 chip 的
//! `Chip::disabled(label)` 构造函数把图标和选中态一起丢了,图上是「另一行」,不是「这一行禁用」。
//!
//! ## 禁用内容角色是 **`OnSurface`**,不是默认的 `OnSurfaceVariant`
//!
//! A1 实读(`javap -p -c androidx.compose.material3.tokens.ListTokens`):
//!
//! ```text
//! ListItemDisabledLabelTextColor = OnSurface (+ ListItemDisabledLabelTextOpacity = 0.38f)
//! ListItemDisabledLeadingIconColor = OnSurface (+ 0.38f)
//! ListItemDisabledTrailingIconColor = OnSurface (+ 0.38f)
//! ```
//!
//! 所以这里必须走 `interaction::disabled_content_over_role(ui, OnSurface, false)`;
//! 用默认的 `disabled_colors()`(写死 `OnSurfaceVariant`)会偏浅一档。
//!
//! ## 选中态:本项目口径(**偏离登记 D-M79-1**)
//!
//! A1 的 `ListItemColors` **没有** selected 容器色(实读字段:containerColor / headline / leadingIcon /
//! overline / supporting / trailingIcon + 三个 disabled),选中只把 trailing 图标换成 `Primary`。
//! mission 要求「选中态(SecondaryContainer)」,且本项目的菜单项(M77)与 drawer 项都用
//! `SecondaryContainer` 表示「当前项」——三处保持同一语言,故 ListItem 也填 `SecondaryContainer`
//! + `OnSecondaryContainer`。**这是实质偏离**:A1 的 ListItem 选中行不换底色。
use iced::widget::text::LineHeight;
use iced::widget::{button, container, space, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::{
typography_headline, typography_overline, typography_supporting, ListItemGeometry,
};
/// 一个列表项(一行 / 两行 / 三行由槽位决定)。
#[derive(Debug, Clone)]
pub struct ListItem<'a, M> {
overline: Option<&'a str>,
headline: &'a str,
supporting: Option<&'a str>,
leading: Option<IconKind>,
trailing: Option<IconKind>,
on_press: Option<M>,
selected: bool,
state: Option<Interaction>,
}
impl<'a, M> ListItem<'a, M> {
/// 一行项(主文本 + 可点消息)。
pub fn new(headline: &'a str, on_press: M) -> Self {
Self {
overline: None,
headline,
supporting: None,
leading: None,
trailing: None,
on_press: Some(on_press),
selected: false,
state: None,
}
}
/// 加一行辅助文本 → **两行**项(4.5:辅助 `BodyMedium`)。
pub fn supporting(mut self, supporting: &'a str) -> Self {
self.supporting = Some(supporting);
self
}
/// 加上方 overline → **三行**项(4.5:overline `LabelSmall`,`OnSurfaceVariant`)。
pub fn overline(mut self, overline: &'a str) -> Self {
self.overline = Some(overline);
self
}
/// 前置槽位(图标 24,`OnSurfaceVariant`)。
pub fn leading(mut self, kind: IconKind) -> Self {
self.leading = Some(kind);
self
}
/// 后置槽位(未选中 `OnSurface`,选中 `Primary`,见 4.8)。
pub fn trailing(mut self, kind: IconKind) -> Self {
self.trailing = Some(kind);
self
}
/// 选中态(`SecondaryContainer` + `OnSecondaryContainer`;偏离 D-M79-1 见模块文档)。
pub fn selected(mut self) -> Self {
self.selected = true;
self
}
/// 强制视觉态(gallery 状态矩阵取证;不动状态机)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 转禁用:**只清消息**,槽位与选中态一律保留。
pub fn disabled(mut self) -> Self {
self.on_press = None;
self
}
/// 行数(1 + 有 overline + 有 supporting)。
pub fn lines(&self) -> usize {
usize::from(self.overline.is_some()) + 1 + usize::from(self.supporting.is_some())
}
}
/// 一项的颜色(按态 + 选中求值;禁用按 A1 的 `OnSurface`@38%)。
#[derive(Debug, Clone, Copy, PartialEq)]
struct ItemPalette {
fill: Option<Color>,
headline: Color,
supporting: Color,
leading: Color,
trailing: Color,
}
fn palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
if state.is_disabled() {
// A1:ListItemDisabled*Color = OnSurface × 0.38(无容器时叠在 surface 上)
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
return ItemPalette {
fill: None,
headline: ink,
supporting: ink,
leading: ink,
trailing: ink,
};
}
if selected {
let fill = interaction::overlay_color(
ui,
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)
.unwrap_or_else(|| ui.color(Role::SecondaryContainer));
return ItemPalette {
fill: Some(fill),
headline: ui.color(Role::OnSecondaryContainer),
supporting: ui.color(Role::OnSecondaryContainer),
leading: ui.color(Role::OnSecondaryContainer),
// 4.8:选中时 trailing 图标是 `Primary`(A1 ListItemSelectedTrailingIconColor)
trailing: ui.color(Role::Primary),
};
}
ItemPalette {
// 状态层叠在**行自己的底**(Surface)上,得到不透明结果(D27)
fill: interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface),
headline: ui.color(Role::OnSurface),
supporting: ui.color(Role::OnSurfaceVariant),
leading: ui.color(Role::OnSurfaceVariant),
trailing: ui.color(Role::OnSurface),
}
}
/// 一行文本(显式行高:三行块的 16+24+20 必须能精确等于 60,靠的就是它)。
fn line<'a, M: 'a>(
ui: &'a Ui,
value: &'a str,
role: unlucky_design::typography::TypeRole,
color: Color,
) -> Element<'a, M> {
let style = ui.style(role);
text(value)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.into()
}
/// 列表项(`research/12b` §4)。
pub fn list_item<'a, M: Clone + 'a>(ui: &'a Ui, item: ListItem<'a, M>) -> Element<'a, M> {
let geometry = ListItemGeometry::of(ui);
let lines = item.lines();
let height = geometry.height(lines);
// 禁用 = 「没有消息」;`state` 是取证强制值,优先级更高(矩阵里画 hover 的禁用项也成立)
let forced = item.state.or(if item.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
let colors = palette(ui, forced.unwrap_or(Interaction::Enabled), item.selected);
let mut body = iced::widget::Column::new().spacing(0);
if let Some(overline) = item.overline {
body = body.push(line::<M>(
ui,
overline,
typography_overline(),
colors.supporting,
));
}
body = body.push(line::<M>(
ui,
item.headline,
typography_headline(),
colors.headline,
));
if let Some(supporting) = item.supporting {
body = body.push(line::<M>(
ui,
supporting,
typography_supporting(),
colors.supporting,
));
}
let mut content = iced::widget::Row::new()
.align_y(Alignment::Center)
.spacing(geometry.gap);
if let Some(kind) = item.leading {
// Q9:图标必须经固定尺寸容器(否则会被按钮的 Fill 约束撑满)
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.leading),
));
}
content = content.push(body);
if let Some(kind) = item.trailing {
// trailing 必须在行尾:label 与 trailing 之间插一条水平 Fill(`inputs/menu.rs` 的现行写法)
content = content.push(space::horizontal());
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.trailing),
));
}
let pad_y = geometry.pad_y(ui, lines);
// 行必须撑满 item 宽度(Fill 空间要有宽度可撑),垂直靠 align_y 居中(`button` 默认贴左上)
let body = container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center);
let selected = item.selected;
let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
let colors = palette(ui, state, selected);
button::Style {
background: colors.fill.map(Background::Color),
text_color: colors.headline,
// 4.x:ListItem 的形状是 `CornerNone`(直角),没有圆角档
border: Border {
radius: ui.radius_dp(ShapeScale::None),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
};
let button = button(body)
.width(Length::Fill)
.height(Length::Fixed(height))
.padding(Padding {
top: pad_y,
right: geometry.pad_end,
bottom: pad_y,
left: geometry.pad_start,
})
.style(style);
let element: Element<'a, M> = match item.on_press {
Some(message) => button.on_press(message).into(),
None => button.into(),
};
element
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,127 @@
//! `list_item` 的单元测试(split 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
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",
},
)
}
/// 槽位决定行数:1 / 2 / 3(三行是 overline + headline + supporting)。
#[test]
fn line_count_follows_the_slots() {
let one = ListItem::<()>::new("Instances", ());
assert_eq!(one.lines(), 1);
let two = ListItem::<()>::new("Instances", ()).supporting("3 versions");
assert_eq!(two.lines(), 2);
let three = ListItem::<()>::new("Instances", ())
.overline("RECENT")
.supporting("3 versions");
assert_eq!(three.lines(), 3);
// overline 单独存在也是两行(overline + headline)
let overline_only = ListItem::<()>::new("Instances", ()).overline("RECENT");
assert_eq!(overline_only.lines(), 2);
}
/// **禁用是修饰器**:只清消息,槽位与选中态全部保留(复审 P2-1 那条)。
#[test]
fn disabled_only_clears_the_message() {
let item = ListItem::new("Instances", ())
.leading(unlucky_design::icons::IconKind::Instances)
.trailing(unlucky_design::icons::IconKind::Settings)
.supporting("3 versions")
.selected()
.disabled();
assert!(item.on_press.is_none(), "禁用必须清消息");
assert!(item.leading.is_some(), "禁用不许删前置图标");
assert!(item.trailing.is_some(), "禁用不许删后置图标");
assert!(item.selected, "禁用不许删选中态");
assert!(item.supporting.is_some(), "禁用不许删槽位");
assert_eq!(item.lines(), 2);
}
/// 禁用内容角色是 **`OnSurface`**@38%(A1 ListTokens),不是默认的 `OnSurfaceVariant`。
#[test]
fn disabled_content_uses_on_surface() {
let ui = ui();
let palette = palette(&ui, Interaction::Disabled, false);
let expected = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
assert_eq!(palette.headline, expected);
assert_eq!(palette.leading, expected);
assert_eq!(palette.trailing, expected);
assert_ne!(
palette.headline,
interaction::disabled_content_over_role(&ui, Role::OnSurfaceVariant, false),
"走默认的 OnSurfaceVariant 就会偏浅一档(这条钉住 A1 的角色)"
);
assert!(palette.fill.is_none(), "禁用不叠状态层容器");
}
/// 选中态:`SecondaryContainer` + `OnSecondaryContainer`,trailing 是 `Primary`(4.8)。
#[test]
fn selected_row_is_secondary_container() {
let ui = ui();
let palette = palette(&ui, Interaction::Enabled, true);
assert_eq!(palette.fill, Some(ui.color(Role::SecondaryContainer)));
assert_eq!(palette.headline, ui.color(Role::OnSecondaryContainer));
assert_eq!(palette.trailing, ui.color(Role::Primary), "4.8");
}
/// 4.7:hover / pressed 只叠状态层,**内容色不变**。
#[test]
fn interaction_states_keep_the_content_colors() {
let ui = ui();
let resting = palette(&ui, Interaction::Enabled, false);
for state in [
Interaction::Hovered,
Interaction::Focused,
Interaction::Pressed,
] {
let active = palette(&ui, state, false);
assert_eq!(active.headline, resting.headline, "{state:?} 不许改文本色");
assert_eq!(active.leading, resting.leading, "{state:?} 不许改图标色");
assert!(active.fill.is_some(), "{state:?} 必须叠状态层");
}
assert_eq!(resting.fill, None, "静息态不叠状态层");
}
/// 三档行高与 `LM.row_height` 的关系**钉死**(有人按 A1 改回 56 时这里会红)。
#[test]
fn heights_are_pinned_to_the_row_height_token() {
let ui = ui();
let geometry = ListItemGeometry::of(&ui);
assert_eq!(geometry.heights[0], ui.metrics().row_height);
assert_eq!(geometry.heights, [44.0, 60.0, 76.0]);
assert_ne!(
geometry.heights[0], 56.0,
"56 是 A1 的值;本项目按 12b §4.1 取 44(改回去会破坏页面排版)"
);
}
/// 元素构造不 panic(`Element` 树的形状也一并过一遍)。
#[test]
fn view_builds_a_tree() {
let ui = ui();
let _: Element<'_, ()> = list_item(
&ui,
ListItem::new("Instances", ())
.overline("RECENT")
.supporting("3 versions")
.leading(unlucky_design::icons::IconKind::Instances)
.trailing(unlucky_design::icons::IconKind::Settings)
.selected(),
);
let _: Element<'_, ()> = list_item(&ui, ListItem::new("Logs", ()).disabled());
}
@@ -1,35 +1,53 @@
//! **导航类家族(桩)**:由后续 mission 填充(§4 ListItem / §6 Tabs / §21 NavigationDrawer / §16 AppBar)。
//! **导航类家族**(`research/12b` §4/§6/§16 + `research/15` §21):四件组件,一个容器。
//!
//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**:
//! 家族模块已经在 `mod.rs` 里声明好,`gallery/navigation.rs` 也已经在 `gallery/mod.rs` 里声明好,
//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。
//! | 组件 | 入口 | 参考章节 | 关键几何 |
//! |---|---|---|---|
//! | ListItem | [`list_item::list_item`] | 12b §4 | 行高 44 / 60 / 76(A1 56/72/88,登记偏离) |
//! | Tabs | [`tabs::tabs`] | 12b §6 | 容器 48、Primary 指示条 3、Secondary 线 1 |
//! | NavigationDrawer | [`drawer::navigation`] | 15 §21 + D33 | **同一个容器**:rail 88 ⇄ drawer 240 |
//! | AppBar small | [`app_bar::app_bar`] | 12b §16 | 页头 56、标题 TitleLarge、滚动换色 + L2 |
//!
//! 填充时请照抄按钮家族的分层:
//! ## 三条纪律(与另外四个家族同源)
//!
//! - 几何进 `navigation/geometry.rs`(数值从 `ComponentMetrics::NAVIGATION_DRAWER` /
//! `LayoutMetrics` / `Space` / `ShapeScale` 取,缺令牌登记 G 编号);
//! - 状态层只能走 [`crate::app::components::interaction`];
//! - 精确尺寸用 [`crate::app::components::layout`]——导航选中指示器 **56×32** 就是 Q8 的历史事故现场,
//! 必须用 `layout::centered_fixed`(`center_x(Length::Fill)` 会把它撑成 88 宽)。
//! 1. **对消息类型泛型**,不 import `crate::app::Message`(契约 §2);`Ui` 一律 `&'a Ui` 传入。
//! 2. **状态层只走** [`crate::app::components::interaction`]:α 与禁用色一处求值;
//! 禁用是**修饰器**(`.disabled()` 只清消息,见 `selection/chip.rs` 复审 P2-1)。
//! 3. **精确尺寸只走** [`crate::app::components::layout`]:图标一律 `layout::fixed`(Q9),
//! 居中交给父级 `align_x`(Q8:`center_x(Fill)` 会把 56 宽药丸撑成 88)。
//!
//! ## 禁用内容角色**按变体**(不许抄默认值)
//!
//! | 组件 | 禁用内容角色 | 出处 |
//! |---|---|---|
//! | ListItem | **`OnSurface`**@38%(不是 `OnSurfaceVariant`) | `[源]` `ListTokens.ListItemDisabledLabel/LeadingIcon/TrailingIconColor = OnSurface` + `…Opacity = 0.38f` |
//! | Drawer / Rail 项 | `OnSurface`@38% | `[估]`(A1 无 disabled token,与 ListItem 同表面语境) |
//! | Tabs | `OnSurface`@38% | `[估]`(同上;A1 的 Tabs 两套 token 只有 Active/Inactive) |
//! | AppBar | — (本轮页头不带禁用动作) | — |
//!
//! ## 令牌缺口(G 编号,续 `research/15` §25 的 G13;G16~G20 已被其他家族占用)
//!
//! - **G22** Tabs 容器高 48(带图标 64)——`LayoutMetrics` 无此档;
//! - **G23** Tabs 指示条高 3 / `TabMinWidth` 90(后者 iced 无法表达,登记为限制);
//! - **G24** drawer 项高 56——与 `LM.nav_item_height`(44) 的差异按 A1 取 56,见 [`geometry`];
//! - **G25** ListItem 两/三行高(60 / 76)——`G4` 要求的 `row_height_1/2/3` 仍缺,本文件按
//! `LM.row_height` + 每行 16 派生(A1 delta 相同)。
//!
//! ## 视觉证据(D30)
//!
//! `gallery/navigation.rs` 把四个组件摆成状态矩阵;`ULL_GALLERY_SECTION=navigation` 只渲染本段,
//! `ULL_NAV_PART=<list|tabs|drawer|appbar|all>` 再切成单块出图(1180×760 一屏装不下四块矩阵)。
//! 渲染层断言在 [`probe`](读 `ULL_NAV_DIR` 下的真截图量像素),纯函数断言在各组件文件的 `tests`。
use iced::widget::{container, text};
use iced::Element;
pub mod app_bar;
pub mod drawer;
pub mod geometry;
pub mod list_item;
#[cfg(test)]
mod probe;
pub mod tabs;
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
/// 占位 demo(ASCII 文案,理由同 `selection::demo`)。
pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
let style = ui.style(TypeRole::BodySmall);
container(
text("navigation · list item / tabs / drawer / app bar — pending")
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant)),
)
.padding(ui.space(Space::X2))
.into()
}
pub use app_bar::{app_bar, AppBar, AppBarAction};
pub use drawer::{navigation, NavGroup, NavItem, Navigation};
pub use geometry::NavLayout;
pub use list_item::{list_item, ListItem};
pub use tabs::{tabs, Tab, Tabs, TabsVariant};
@@ -0,0 +1,342 @@
//! 渲染层几何断言:**对着真截图量像素**(M68 的教训——纯函数返回对,图可能是错的)。
//!
//! ## 为什么自带一份最小读图器
//!
//! `gallery::scan` 是 `pub(super)`(私有于 `gallery`),而 `gallery/mod.rs` 不在本 mission 的 scope 里,
//! 所以这里自带 ~60 行读图器(读 PNG / 取点 / 连通块 / 横带墨迹范围),判据全在本文件。
//! 与 `inputs/probe.rs` 同一个处置(已作为 finding 报给 tower:建议把 `gallery::scan` 提升为 `pub(crate)`)。
//!
//! ## 怎么跑(worktree 根目录)
//!
//! ```text
//! # ① 出 4 张图(每块一条命令,块选择器见 gallery/navigation.rs 的模块文档)
//! ULL_GALLERY_SECTION=navigation ULL_NAV_PART=list ./target/debug/unlucky-launcher-iced.exe \
//! --software-renderer --screen=gallery --screenshot target/m79/list-light.png --screenshot-theme=light
//! # ② 量图
//! ULL_NAV_DIR=target/m79 cargo test -p unlucky-app-iced navigation::probe
//! ```
//!
//! 没给 `ULL_NAV_DIR` 就**大声跳过**(不静默通过),与 `gallery::probe` 的口径一致。
//!
//! ## 量什么(每条都对着一个「已经画错过」的形态)
//!
//! | 断言 | 抓的错误 |
//! |---|---|
//! | ListItem 三行高 **44 / 60 / 76** | 抄 A1 的 56/72/88、或用内容撑高(行高被文本吃掉) |
//! | ListItem 禁用内容 = `OnSurface`@38% | 走 `disabled_colors()` 的默认 `OnSurfaceVariant` |
//! | Primary 指示条宽 = 选中项文本宽 + 2×16 | 条写成 `Fill`(= 整行宽,Q8 的另一面)或写死 |
//! | drawer **240** / rail **88**,药丸内缩 **12** / **16** | 药丸不贴边(Q8)、或两套组件各写死一个宽度 |
//! | AppBar **56** 高(静息 + 滚动) | 页头被内容撑高、或按 A1 改回 64 |
use std::path::{Path, PathBuf};
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::spacing::Space;
use crate::app::components::interaction;
mod scan;
use scan::{rgb, ui, BBox, Rgb, Shot};
/// `ULL_NAV_DIR` → 截图目录(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。
fn shot_dir() -> Option<PathBuf> {
let raw = std::env::var_os("ULL_NAV_DIR")?;
let path = PathBuf::from(raw);
if path.is_absolute() || path.exists() {
return Some(path);
}
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
Some(root.join(path))
}
macro_rules! require_dir {
() => {
match shot_dir() {
Some(dir) => dir,
None => {
eprintln!(
"[navigation::probe] SKIPPED:未设置 ULL_NAV_DIR。这是**渲染层**断言,\
需要先按本文件模块文档出 4 张图(target/m79/*.png)。"
);
return;
}
}
};
}
fn shot(dir: &Path, part: &str, flavor: SchemeFlavor) -> Shot {
let slug = match flavor {
SchemeFlavor::Light => "light",
SchemeFlavor::Dark => "dark",
};
Shot::load(&dir.join(format!("{part}-{slug}.png")))
}
/// ListItem:三行高 **44 / 60 / 76**(选中行的 `SecondaryContainer` 底就是行高)。
#[test]
fn list_item_rows_are_44_60_76() {
let dir = require_dir!();
let ui = ui(SchemeFlavor::Light);
let shot = shot(&dir, "list", SchemeFlavor::Light);
// 矩阵格宽 240(`nav_drawer_width`):选中列的三块填充就是三条行
let selected = rgb(ui.color(Role::SecondaryContainer));
let mut rows: Vec<BBox> = shot
.blocks(selected)
.into_iter()
.filter(|b| (238..=242).contains(&b.width()) && b.height() > 30)
.collect();
rows.sort_by_key(|b| b.top);
assert_eq!(
rows.len(),
3,
"选中列应有 3 条(一 / 两 / 三行),实际 {} —— 图里格子宽度或选中底丢了",
rows.len()
);
let heights: Vec<usize> = rows.iter().map(BBox::height).collect();
// 边缘那一行是抗锯齿(半覆盖)→ 实测常常少 1px;判 43..=44 / 59..=60 / 75..=76,
// 而 A1 的 56/72/88 仍然落区间外(这条照样能被打红)。
for (measured, expected) in heights.iter().zip([44usize, 60, 76]) {
assert!(
(expected - 1..=expected).contains(measured),
"12b §4.1/4.2 的项目口径是 44/60/76(A1 是 56/72/88);实测 {heights:?}"
);
}
// 禁用内容角色 = `OnSurface`@38%(A1 ListTokens),不是默认的 `OnSurfaceVariant`@38%。
// 判据:禁用格里**最深**的像素就是核心笔画,它必须精确等于 A1 的那个混合色。
let cell = rows[0].width() + 8; // 格宽 240 + 列间距 8
let disabled = BBox {
left: rows[0].left + cell * 2,
right: (rows[0].left + cell * 2 + rows[0].width() - 1).min(shot.width - 1),
top: rows[0].top,
bottom: rows[2].bottom,
};
let darkest = shot.darkest(&disabled).expect("禁用格里应有墨迹");
let right = rgb(interaction::disabled_content_over_role(
&ui,
Role::OnSurface,
false,
));
let wrong = rgb(interaction::disabled_content_over_role(
&ui,
Role::OnSurfaceVariant,
false,
));
let near = |a: Rgb, b: Rgb| {
a.iter()
.zip(b)
.all(|(x, y)| x.abs_diff(y) <= scan::TOLERANCE)
};
assert!(
near(darkest, right),
"禁用内容最深像素是 {darkest:?},A1 的 OnSurface@38% 是 {right:?}(用 OnSurfaceVariant 会得到 {wrong:?})"
);
assert!(
!near(darkest, wrong),
"禁用内容用了 OnSurfaceVariant@38%({wrong:?})而不是 A1 的 OnSurface@38%({right:?})"
);
eprintln!(
"[navigation::probe] list: 行高 {heights:?},禁用最深像素 {darkest:?}(A1 = {right:?})"
);
}
/// Tabs:Primary 指示条宽 = **选中项**文本宽 + 2×16(短/长文案各选中一次 → 两条宽度必须不同)。
#[test]
fn tabs_indicator_tracks_the_selected_tab() {
let dir = require_dir!();
let ui = ui(SchemeFlavor::Light);
let shot = shot(&dir, "tabs", SchemeFlavor::Light);
let primary = rgb(ui.color(Role::Primary));
let surface = rgb(ui.color(Role::Surface));
// 指示条:3px 高、宽 ≥ 40 的 `Primary` 块(选中项的标签是同色,但笔画块远小于 40 宽)
let mut bars: Vec<BBox> = shot
.blocks(primary)
.into_iter()
.filter(|b| b.height() <= 4 && b.width() >= 40)
.collect();
bars.sort_by_key(|b| b.top);
assert!(
bars.len() >= 2,
"至少应有 2 条 Primary 指示条(短文案选中一行 + 长文案选中一行),实际 {}",
bars.len()
);
// 前两条 = 两行 Primary(后面还有「五态」那一行的选中条,不参与「跟随宽度」的判定)
bars.truncate(2);
let mut measured = Vec::new();
for bar in &bars {
// 该行的标签墨迹:指示条上方 2..32 像素、**指示条自身跨度内**与底色不同的像素范围
let span = shot
.band_ink(
surface,
bar.left,
bar.right,
bar.top.saturating_sub(32),
bar.top.saturating_sub(2),
)
.expect("指示条上方应能找到选中标签的墨迹");
let ink = span.1 - span.0 + 1;
let expected = ink + 32; // 2 × pad(16)
assert!(
bar.width() >= ink,
"指示条 {} 比标签墨迹 {ink} 还窄",
bar.width()
);
assert!(
bar.width() <= expected + 12,
"指示条宽 {} 远大于「标签墨迹 {ink} + 2×16 = {expected}」——条被写成了整行宽(Q8)",
bar.width()
);
assert!(
bar.width() + 12 >= expected,
"指示条宽 {} 小于「标签墨迹 {ink} + 2×16 = {expected}」——条没有覆盖内边距",
bar.width()
);
measured.push((bar.width(), ink));
}
let (short_bar, long_bar) = (measured[0].0, measured[1].0);
assert!(
long_bar > short_bar + 20,
"两行的指示条宽度必须随选中项变化(短文案 {short_bar} / 长文案 {long_bar})——\
写死或 Fill 的条会一样宽"
);
eprintln!("[navigation::probe] tabs: 指示条 {measured:?}(宽, 标签墨迹宽)");
}
/// Drawer:**同一个容器两个宽度** —— rail 88 / drawer 240,药丸 56×32 / 216×56,内缩 16 / 12(D33)。
#[test]
fn drawer_is_240_and_rail_is_88_with_inset_pills() {
let dir = require_dir!();
let ui = ui(SchemeFlavor::Light);
let shot = shot(&dir, "drawer", SchemeFlavor::Light);
let pad = Space::X2.dp();
let plate = rgb(ui.color(Role::SurfaceContainerLow));
let mut plates: Vec<BBox> = shot
.blocks(plate)
.into_iter()
.filter(|b| b.height() > 150)
.collect();
plates.sort_by_key(|b| b.left);
assert_eq!(
plates.len(),
2,
"应有 2 块取景板(rail 与 drawer),实际 {}",
plates.len()
);
let (rail_plate, drawer_plate) = (plates[0], plates[1]);
// 取景板比导航容器大一圈(左右各 pad):板宽 − 2×pad 才是容器宽
let rail = rail_plate.width() as f32 - pad * 2.0;
let drawer = drawer_plate.width() as f32 - pad * 2.0;
assert_eq!(rail, 88.0, "LayoutMetrics::nav_rail_width");
assert_eq!(drawer, 240.0, "LayoutMetrics::nav_drawer_width");
let pill = rgb(ui.color(Role::SecondaryContainer));
let mut pills: Vec<BBox> = shot
.blocks(pill)
.into_iter()
.filter(|b| b.width() > 40 && b.height() > 20)
.collect();
pills.sort_by_key(|b| b.left);
assert_eq!(
pills.len(),
2,
"rail 与 drawer 各一个选中药丸,实际 {}",
pills.len()
);
let (rail_pill, drawer_pill) = (pills[0], pills[1]);
// rail:药丸 56×32,左右各缩 16(88 − 2×16 = 56,M68 的结论)
// 边缘抗锯齿可能少 1px(32 → 31);判 ±1 区间,216/56 的 drawer 药丸同理
assert!(
(55..=56).contains(&rail_pill.width()) && (31..=32).contains(&rail_pill.height()),
"12b §5.4:rail 药丸应是 56×32,实测 {}×{}",
rail_pill.width(),
rail_pill.height()
);
assert_eq!(
rail_pill.left - (rail_plate.left + pad as usize),
16,
"rail 药丸左内缩必须是 16(Q8 回归时这里是 0,药丸会贴边)"
);
// drawer:药丸 216×56,左右各缩 12(A1 `NavigationDrawerItemDefaults.ItemPadding = 12`)
assert!(
(215..=216).contains(&drawer_pill.width()) && (55..=56).contains(&drawer_pill.height()),
"21.5:drawer 药丸应是 216×56(240 − 2×12),实测 {}×{}",
drawer_pill.width(),
drawer_pill.height()
);
assert_eq!(
drawer_plate.width() - drawer_pill.width(),
24 + pad as usize * 2,
"药丸两侧内缩之和必须是 24(12 + 12),再加取景板的两圈 pad"
);
assert_eq!(
drawer_pill.left - (drawer_plate.left + pad as usize),
12,
"ItemPadding"
);
// 圆角语言不变:两条药丸都居中(左右内缩相等)
assert_eq!(
rail_plate.right - pad as usize - rail_pill.right,
rail_pill.left - (rail_plate.left + pad as usize),
"rail 药丸必须水平居中(两侧内缩相等)"
);
eprintln!(
"[navigation::probe] drawer: rail {rail} / drawer {drawer},药丸 {}×{} / {}×{},内缩 {} / {}",
rail_pill.width(),
rail_pill.height(),
drawer_pill.width(),
drawer_pill.height(),
rail_pill.left - (rail_plate.left + pad as usize),
drawer_pill.left - (drawer_plate.left + pad as usize)
);
}
/// AppBar:静息 56 高(`Surface`)、滚动 56 高(`SurfaceContainer`)——高度不随滚动变化。
#[test]
fn app_bar_is_56_high_in_both_states() {
let dir = require_dir!();
let ui = ui(SchemeFlavor::Light);
let shot = shot(&dir, "appbar", SchemeFlavor::Light);
let resting = rgb(ui.color(Role::Surface));
let scrolled = rgb(ui.color(Role::SurfaceContainer));
let mut measured = Vec::new();
for (name, color) in [("静息", resting), ("滚动", scrolled)] {
let bars: Vec<BBox> = shot
.blocks(color)
.into_iter()
.filter(|b| b.width() > 300 && (54..=58).contains(&b.height()))
.collect();
assert_eq!(
bars.len(),
1,
"{name} AppBar 应恰好一条(56 高、>300 宽),实际 {} 条",
bars.len()
);
measured.push((name, bars[0].height()));
}
// 两条同高:滚动只换容器色与高程,不改高度(A1 只有 `OnScrollContainerColor` / `OnScrollContainerElevation`)
// 滚动态带 L2 阴影,阴影会把边缘那一行染成接近容器的颜色 → 允许差 1px
assert!(
(measured[0].1 as i32 - measured[1].1 as i32).abs() <= 1,
"静息与滚动必须同高(滚动不改高度),实测 {measured:?}"
);
// 抗锯齿让边缘行可能差 1px:判 55..=57;A1 的 64 必须落区间外
assert!(
(55..=57).contains(&measured[0].1),
"AppBar 高度应是 56(LM.page_header_height),实测 {measured:?}"
);
assert!(
!(63..=65).contains(&measured[0].1),
"64 是 A1 AppBarSmallTokens.ContainerHeight;本项目按骨架令牌取 56(D-M79-2)"
);
eprintln!("[navigation::probe] appbar: 高度 {measured:?}");
}
@@ -0,0 +1,188 @@
//! `probe` 的读图器(`probe.rs` 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
//!
//! 只做三件事:读 PNG、找同色的连通块(`BBox`)、量一条横带里某颜色的墨迹范围。
//! 判据全在 `probe.rs`。
use std::path::Path;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::{Families, Ui};
/// 字体族(与 `gallery::probe` 同一套)。
pub(super) const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 颜色比对容差。
///
/// **3 而不是 10**:本家族要区分的几个容器色本身很接近(light 下
/// `SurfaceContainerLow (235,245,255)` vs `SurfaceContainer (227,239,251)` 只差 8/6/4),
/// 容差 10 会把「取景板」和「滚动后的 AppBar」连成一块,量出来的高度就不对了。
/// 实色区域是精确值,3 足够吸收抗锯齿的取整误差。
pub(super) const TOLERANCE: u8 = 3;
pub(super) type Rgb = [u8; 3];
pub(super) fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
pub(super) fn rgb(color: iced::Color) -> Rgb {
[
(color.r * 255.0).round() as u8,
(color.g * 255.0).round() as u8,
(color.b * 255.0).round() as u8,
]
}
pub(super) fn matches(pixel: Rgb, expected: Rgb) -> bool {
pixel
.iter()
.zip(expected)
.all(|(a, b)| a.abs_diff(b) <= TOLERANCE)
}
/// 一个外接框(闭区间)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct BBox {
pub(super) left: usize,
pub(super) right: usize,
pub(super) top: usize,
pub(super) bottom: usize,
}
impl BBox {
pub(super) fn width(&self) -> usize {
self.right - self.left + 1
}
pub(super) fn height(&self) -> usize {
self.bottom - self.top + 1
}
}
/// 一张图的像素访问器。
pub(super) struct Shot {
pub(super) width: usize,
pub(super) height: usize,
pixels: Vec<Rgb>,
}
impl Shot {
pub(super) fn load(path: &Path) -> Self {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读不动 {path:?}:{e}"));
let image = image::load_from_memory(&bytes)
.unwrap_or_else(|e| panic!("解 PNG 失败 {path:?}:{e}"))
.to_rgb8();
assert_eq!(
(image.width() as usize, image.height() as usize),
(1180, 760),
"验收图必须是 D30 的 1180×760 @100% DPI"
);
Self {
width: image.width() as usize,
height: image.height() as usize,
pixels: image.pixels().map(|p| p.0).collect(),
}
}
pub(super) fn at(&self, x: usize, y: usize) -> Rgb {
self.pixels[y * self.width + x]
}
/// 某种颜色的**全部**连通块(面积 ≥ 24;文字笔画太碎的块直接丢掉)。
pub(super) fn blocks(&self, color: Rgb) -> Vec<BBox> {
let mut seen = vec![false; self.width * self.height];
let mut found = Vec::new();
for y in 0..self.height {
for x in 0..self.width {
if seen[y * self.width + x] || !matches(self.at(x, y), color) {
continue;
}
let mut stack = vec![(x, y)];
seen[y * self.width + x] = true;
let mut bbox = BBox {
left: x,
right: x,
top: y,
bottom: y,
};
while let Some((cx, cy)) = stack.pop() {
bbox.left = bbox.left.min(cx);
bbox.right = bbox.right.max(cx);
bbox.top = bbox.top.min(cy);
bbox.bottom = bbox.bottom.max(cy);
for (nx, ny) in [
(cx + 1, cy),
(cx.saturating_sub(1), cy),
(cx, cy + 1),
(cx, cy.saturating_sub(1)),
] {
if nx < self.width
&& ny < self.height
&& !seen[ny * self.width + nx]
&& matches(self.at(nx, ny), color)
{
seen[ny * self.width + nx] = true;
stack.push((nx, ny));
}
}
}
if bbox.width() * bbox.height() >= 24 {
found.push(bbox);
}
}
}
found
}
/// 矩形内**最深**的像素(禁用的核心笔画就是精确的混合色,用它判角色最稳)。
pub(super) fn darkest(&self, bbox: &BBox) -> Option<Rgb> {
let mut best: Option<(u32, Rgb)> = None;
for y in bbox.top..=bbox.bottom.min(self.height - 1) {
for x in bbox.left..=bbox.right.min(self.width - 1) {
let pixel = self.at(x, y);
let sum: u32 = pixel.iter().map(|c| u32::from(*c)).sum();
if best.is_none_or(|(current, _)| sum < current) {
best = Some((sum, pixel));
}
}
}
best.map(|(_, pixel)| pixel)
}
/// 矩形内「与底色不同」的像素的左右范围(量文本墨迹用)。
///
/// 为什么不按精确文字色找:小字号抗锯齿后几乎没有像素落在**精确**色上(容差 3 时实测
/// 「Logs」只剩 12px)。改成「与底色不同即墨迹」,既与字色无关,也不会被状态层带偏。
pub(super) fn band_ink(
&self,
background: Rgb,
x0: usize,
x1: usize,
y0: usize,
y1: usize,
) -> Option<(usize, usize)> {
let mut span: Option<(usize, usize)> = None;
for y in y0..=y1.min(self.height - 1) {
for x in x0..=x1.min(self.width - 1) {
let pixel = self.at(x, y);
let differs = pixel
.iter()
.zip(background)
.any(|(a, b)| a.abs_diff(b) > 12);
if differs {
span = Some(match span {
Some((l, r)) => (l.min(x), r.max(x)),
None => (x, x),
});
}
}
}
span
}
}
@@ -0,0 +1,325 @@
//! Tabs(`research/12b` §6):**Primary**(下方 3px 指示条)与 **Secondary**(1px 分隔线)。
//!
//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR)
//!
//! ```text
//! PrimaryNavigationTabTokens: ContainerHeight 48、IconAndLabelTextContainerHeight 64、
//! ActiveIndicatorHeight 3、ActiveIndicatorShape RoundedCornerShape(3dp)、
//! IconSize 24、ContainerShape CornerNone、LabelTextFont TitleSmall
//! ActiveIndicatorColor=Primary;Active*/Inactive* 各态内容色(见下表)
//! SecondaryNavigationTabTokens: ContainerHeight 48、DividerHeight、DividerColor=SurfaceVariant、
//! ActiveLabelTextColor=OnSurface、InactiveLabelTextColor=OnSurfaceVariant
//! ```
//!
//! | 项 | Primary | Secondary |
//! |---|---|---|
//! | 选中内容色 | **`Primary`** | **`OnSurface`**(不是 Primary——6.5 明写) |
//! | 未选内容色 | `OnSurfaceVariant` | `OnSurfaceVariant` |
//! | hover / focus / pressed | 未选项提到 **`OnSurface`**,选中项保持不变 | 同左 |
//! | 底部条 | 仅选中项:**3px `Primary`**,宽度**跟随该 tab** | 所有项:**1px `SurfaceVariant`**(连成一条线) |
//!
//! 尺寸/圆角/线厚全部来自 [`TabsGeometry`](**G22/G23**:`LayoutMetrics` 没有 Tabs 档)。
//!
//! ## 指示条怎么做到「宽度跟随选中项」
//!
//! iced 没有「取到渲染后文本宽度」的公开 API,所以这里用 [`stack`] 的**分层语义**:
//!
//! 1. `Stack` 的**底层**是 tab 自己的内容容器(`Length::Shrink` → 宽度 = 文本实测宽 + 2×16);
//! 2. `Stack` 的**上层**是 `Length::Fill` 的指示条。`Stack::layout` 先按**底层**定尺寸,再用
//! 「已知尺寸」的 `Limits` 布局其余层(`iced_widget-0.14.2/src/stack.rs` 的
//! `size = limits.resolve(self.width, self.height, base.size())`)——于是 Fill 的指示条
//! 拿到的正好是「这个 tab 的宽」,**不是**整行宽。
//!
//! 这也顺带避开 Q8 的另一面:把条写成 `center_x(Fill)` 会把它撑成整行。
//!
//! ## 横向滚动
//!
//! 整行外面套 `scrollable`(`Direction::Horizontal`,滚动条 `Scrollbar::hidden()`):
//! tab 宽度因此是**内容宽**而不是「行宽 ÷ n」——官方 `ScrollableTabRow` 的口径。
//! `TabMinWidth = 90` **本轮不施加**(iced 无 min-width,登记在 G23)。
use iced::widget::text::LineHeight;
use iced::widget::{button, container, scrollable, space, text, Row, Stack};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::{TabsGeometry, TABS_ICON_HEIGHT};
/// 两种 Tabs(`research/12b` §6;M3 的 `PrimaryTabRow` / `SecondaryTabRow`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TabsVariant {
/// 主标签页:选中项下方 3px `Primary` 指示条。
Primary,
/// 次标签页:1px `SurfaceVariant` 分隔线 + 选中项 `OnSurface` 文字。
Secondary,
}
impl TabsVariant {
/// 两种变体(gallery 状态矩阵按这个次序出图)。
pub const ALL: [TabsVariant; 2] = [TabsVariant::Primary, TabsVariant::Secondary];
/// 稳定名字(截图文件名 / 图例用)。
pub const fn name(self) -> &'static str {
match self {
TabsVariant::Primary => "primary",
TabsVariant::Secondary => "secondary",
}
}
/// 是否是 Primary(指示条 3px)。
pub const fn is_primary(self) -> bool {
matches!(self, TabsVariant::Primary)
}
}
/// 一个标签页。
#[derive(Debug, Clone)]
pub struct Tab<'a, M> {
label: &'a str,
icon: Option<IconKind>,
selected: bool,
on_press: Option<M>,
state: Option<Interaction>,
}
impl<'a, M> Tab<'a, M> {
/// 一个未选中的 tab。
pub fn new(label: &'a str, on_press: M) -> Self {
Self {
label,
icon: None,
selected: false,
on_press: Some(on_press),
state: None,
}
}
/// 选中(内容色换成激活档 + 画指示条)。
pub fn selected(mut self) -> Self {
self.selected = true;
self
}
/// 前置图标(24,`IconSize::Toolbar`;带图标的容器高官方是 64,本轮只做文字档)。
pub fn icon(mut self, kind: IconKind) -> Self {
self.icon = Some(kind);
self
}
/// 强制视觉态(gallery 取证;不动状态机)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 转禁用:**只清消息**(修饰器,不是构造函数)。
pub fn disabled(mut self) -> Self {
self.on_press = None;
self
}
}
/// 一行标签页。
#[derive(Debug, Clone)]
pub struct Tabs<'a, M> {
variant: TabsVariant,
tabs: Vec<Tab<'a, M>>,
}
impl<'a, M> Tabs<'a, M> {
/// Primary tabs(3px 指示条)。
pub fn primary() -> Self {
Self {
variant: TabsVariant::Primary,
tabs: Vec::new(),
}
}
/// Secondary tabs(1px 分隔线)。
pub fn secondary() -> Self {
Self {
variant: TabsVariant::Secondary,
tabs: Vec::new(),
}
}
/// 追加一个 tab。
pub fn tab(mut self, tab: Tab<'a, M>) -> Self {
self.tabs.push(tab);
self
}
}
/// 一个 tab 的内容色(A1 的 `Active*` / `Inactive*` / 各态表,见模块文档)。
fn content_color(ui: &Ui, primary: bool, state: Interaction, selected: bool) -> Color {
if state.is_disabled() {
// A1 的 Tabs 没有 Disabled* token(实读:Primary/Secondary 两套都只有 Active/Inactive × 四态),
// 按 ListItem 的 38% 口径处理 —— 登记 `[估]`。
return interaction::disabled_content_over_role(ui, Role::OnSurface, false);
}
if selected {
// 6.5:Primary 选中 `Primary`;Secondary 选中 `OnSurface`
return if primary {
ui.color(Role::Primary)
} else {
ui.color(Role::OnSurface)
};
}
match state {
// 未选中静息 `OnSurfaceVariant`;hover/focus/pressed 提到 `OnSurface`(A1 的 Inactive* 四态)
Interaction::Enabled => ui.color(Role::OnSurfaceVariant),
_ => ui.color(Role::OnSurface),
}
}
/// 指示条 / 分隔线的颜色(Primary 只给选中项;Secondary 每项都有)。
fn bar_color(ui: &Ui, primary: bool, state: Interaction, selected: bool) -> Option<Color> {
if primary {
// 禁用项的指示条也降档(否则「点不动却亮着」)
if !selected {
return None;
}
if state.is_disabled() {
return Some(interaction::disabled_content_over_role(
ui,
Role::OnSurface,
false,
));
}
Some(ui.color(Role::Primary))
} else {
// 6.6:Secondary 的分隔线 `SurfaceVariant`(不是 OutlineVariant——那是 §22 的分隔线)
Some(ui.color(Role::SurfaceVariant))
}
}
/// 一行标签页(自带横向滚动)。
pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> {
let geometry = TabsGeometry::of(ui);
let primary = tabs.variant.is_primary();
// 6.1:带图标时容器高 **64**(`IconAndLabelTextContainerHeight`),纯文字 48
let height = if tabs.tabs.iter().any(|tab| tab.icon.is_some()) {
TABS_ICON_HEIGHT
} else {
geometry.height
};
let mut row = Row::new()
.height(Length::Fixed(height))
.align_y(Alignment::Center);
for tab in tabs.tabs {
row = row.push(tab_cell(ui, tab, geometry, primary, height));
}
scrollable(row)
.direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::hidden(),
))
.height(Length::Fixed(height))
.into()
}
/// 一个 tab 单元(内容 + 底部条)。
fn tab_cell<'a, M: Clone + 'a>(
ui: &'a Ui,
tab: Tab<'a, M>,
geometry: TabsGeometry,
primary: bool,
height: f32,
) -> Element<'a, M> {
let forced = tab.state.or(if tab.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
let state = forced.unwrap_or(Interaction::Enabled);
let selected = tab.selected;
let color = content_color(ui, primary, state, selected);
let style = ui.style(TypeRole::TitleSmall);
let label = text(tab.label)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color);
let mut content = Row::new().align_y(Alignment::Center).spacing(geometry.pad);
if let Some(kind) = tab.icon {
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, color),
));
}
content = content.push(label);
let bar_height = geometry.bar(primary);
// 底层:内容自己决定宽度(Shrink),底部留出条的高度 —— 未选中的 Primary tab 也占同样的高
let base = container(content)
.height(Length::Fixed(height))
.align_y(Alignment::Center)
.padding(Padding {
top: 0.0,
right: geometry.pad,
bottom: bar_height,
left: geometry.pad,
});
let bar = bar_color(ui, primary, state, selected);
let radius = geometry.bar_radius(primary);
let mut stack = Stack::new().push(base);
if let Some(color) = bar {
// 上层:Fill —— `Stack` 先按底层定尺寸,所以这里的 Fill = 这个 tab 的宽(不是整行)
stack = stack.push(
container(
container(space::vertical())
.width(Length::Fill)
.height(Length::Fixed(bar_height))
.style(move |_theme| container::Style {
background: Some(Background::Color(color)),
border: Border {
radius,
..Border::default()
},
..container::Style::default()
}),
)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::End),
);
}
let fill = interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface);
let button = button(stack)
.height(Length::Fixed(height))
.padding(0)
.style(move |_theme, _status| button::Style {
background: fill.map(Background::Color),
text_color: color,
// ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上)
border: Border {
radius: ui.radius_dp(ShapeScale::None),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
});
match tab.on_press {
Some(message) => button.on_press(message).into(),
None => button.into(),
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,137 @@
//! `tabs` 的单元测试(split 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
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",
},
)
}
/// 6.5:选中色 Primary 用 `Primary`,Secondary 用 `OnSurface`(**不是** Primary)。
#[test]
fn active_colors_follow_the_variant() {
let ui = ui();
assert_eq!(
content_color(&ui, true, Interaction::Enabled, true),
ui.color(Role::Primary)
);
assert_eq!(
content_color(&ui, false, Interaction::Enabled, true),
ui.color(Role::OnSurface),
"6.5:Secondary 的选中项是 OnSurface"
);
assert_ne!(
content_color(&ui, false, Interaction::Enabled, true),
ui.color(Role::Primary),
"把 Secondary 的选中色写成 Primary 是最常见的串线"
);
}
/// 未选中静息 `OnSurfaceVariant`;hover / focus / pressed 提到 `OnSurface`。
#[test]
fn inactive_states_lift_to_on_surface() {
let ui = ui();
for primary in [true, false] {
assert_eq!(
content_color(&ui, primary, Interaction::Enabled, false),
ui.color(Role::OnSurfaceVariant)
);
for state in [
Interaction::Hovered,
Interaction::Focused,
Interaction::Pressed,
] {
assert_eq!(
content_color(&ui, primary, state, false),
ui.color(Role::OnSurface),
"{state:?} 必须提到 OnSurface"
);
}
}
}
/// 6.2 / 6.6:Primary 只有选中项有 3px `Primary` 条;Secondary 每项都有 1px `SurfaceVariant`。
#[test]
fn bars_follow_the_variant() {
let ui = ui();
assert_eq!(bar_color(&ui, true, Interaction::Enabled, false), None);
assert_eq!(
bar_color(&ui, true, Interaction::Enabled, true),
Some(ui.color(Role::Primary))
);
assert_eq!(
bar_color(&ui, false, Interaction::Enabled, false),
Some(ui.color(Role::SurfaceVariant)),
"6.6:分隔线是 SurfaceVariant"
);
assert_ne!(
bar_color(&ui, false, Interaction::Enabled, false),
Some(ui.color(Role::OutlineVariant)),
"OutlineVariant 是 §22 分隔线的角色,Tabs 不用它"
);
}
/// 禁用是**修饰器**:只清消息,label / 选中态 / 图标全部保留。
#[test]
fn disabled_only_clears_the_message() {
let tab = Tab::new("Mods", ())
.icon(unlucky_design::icons::IconKind::Settings)
.selected()
.disabled();
assert!(tab.on_press.is_none());
assert!(tab.icon.is_some());
assert!(tab.selected);
assert_eq!(tab.label, "Mods");
}
/// 指示条宽度 = tab 宽 = 文本宽 + 2×16(这条是「跟随选中项宽度」的公式面)。
#[test]
fn indicator_width_is_the_tab_width() {
let geometry = TabsGeometry::of(&ui());
let tab_width = |text_width: f32| text_width + geometry.pad * 2.0;
assert_eq!(tab_width(0.0), 32.0, "空文本 = 2×16");
// 两段不同长度的文案 ⇒ 指示条宽度必然不同(渲染层断言用它打红)
let short = tab_width(40.0);
let long = tab_width(120.0);
assert_eq!(long - short, 80.0);
assert!(long > short);
}
/// 6.1:带图标的 Tabs 容器高 64(`IconAndLabelTextContainerHeight`),纯文字 48。
#[test]
fn icon_tabs_use_the_taller_container() {
let ui = ui();
assert_eq!(TABS_ICON_HEIGHT, 64.0);
assert!(TABS_ICON_HEIGHT > TabsGeometry::of(&ui).height);
}
/// 3 个 tab 的树能建出来(含选中 / 禁用 / 带图标三条路径)。
#[test]
fn view_builds_a_tree() {
let ui = ui();
let _: Element<'_, ()> = tabs(
&ui,
Tabs::primary()
.tab(Tab::new("General", ()).selected())
.tab(Tab::new("Mods", ()).icon(unlucky_design::icons::IconKind::Settings))
.tab(Tab::new("Logs", ()).disabled()),
);
let _: Element<'_, ()> = tabs(
&ui,
Tabs::secondary()
.tab(Tab::new("Release", ()).selected())
.tab(Tab::new("Snapshot", ())),
);
}