Merge branch 'feat/p4-iced-components-navigation-listitem-t'
This commit is contained in:
commit
c825e1aa3d
17 files changed
+3753
-39
No files matched your search
@@ -1,22 +1,352 @@
|
||||
//! gallery 里的导航类小节(**桩**):后续 mission 只改本文件。
|
||||
//! gallery 里的导航类小节:四块状态矩阵 + 一块长文案块(**断言在 `navigation::probe`**)。
|
||||
//!
|
||||
//! ## 块选择器 `ULL_NAV_PART`
|
||||
//!
|
||||
//! `ULL_GALLERY_SECTION=navigation` 只隔离家族;本家族自己就有四块矩阵 + 长文案块,竖排比 760 高,
|
||||
//! 一屏只拍得到第一块。所以按契约 §9.2 的同一套规则再切一层(环境变量 + 默认兼容 + 未知值回落并打
|
||||
//! stderr):`ULL_NAV_PART=list|tabs|drawer|appbar|long|all`(默认 `all`)。
|
||||
//!
|
||||
//! ```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(选中第 1/2/3 个)+ 一行 Secondary + 一行五态
|
||||
//! Drawer rail 88 与 drawer 240 **同尺度并排**(D33),各 3 项、第 2 项 hover、第 3 项禁用
|
||||
//! AppBar 静息 / 滚动两态(56 高;滚动只换容器色 + L2)
|
||||
//! long 长文案:页头 + 三行 ListItem + drawer(单行约束 D-M79-5 的取证)
|
||||
//! ```
|
||||
//!
|
||||
//! 取景板(`SurfaceContainerLow`)不是装饰:`Surface` 的页头/导航与页面底色同色,不垫一层板子,
|
||||
//! 截图里量不到边界(probe 就是靠板量 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::{
|
||||
self, 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" | "long" => name,
|
||||
_ => {
|
||||
eprintln!(
|
||||
"[gallery] {PART_ENV}={raw:?} 不是已知块(all|list|tabs|drawer|appbar|long),已回落 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));
|
||||
}
|
||||
if picked == "long" {
|
||||
body = body.push(navigation::matrix::long_part(ui, pressed));
|
||||
}
|
||||
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 {
|
||||
// 三列的**内容完全相同**(同样的槽位),只有态不同——否则「选中 trailing = Primary」
|
||||
// 这条结论没有可比对象(视觉 r1 P2-2 点名)
|
||||
let mut item = ListItem::new(lines, pressed())
|
||||
.leading(icon("instances"))
|
||||
.trailing(icon("settings"));
|
||||
if index >= 1 {
|
||||
item = item.supporting("3 versions · 1.21.4");
|
||||
}
|
||||
if index >= 2 {
|
||||
item = item.overline("RECENT");
|
||||
}
|
||||
item = match state {
|
||||
"selected" => item.selected(),
|
||||
// 禁用是修饰器:这里只清消息,槽位全部保留
|
||||
"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,353 @@
|
||||
//! 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, Wrapping};
|
||||
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;
|
||||
// 图标要落在 40×40 命中区的**正中**:`Container` 默认 `horizontal_alignment: Left`
|
||||
// (`iced_widget-0.14.2/src/container.rs`),只给 `align_y` 会让 24px 图标贴左缘——
|
||||
// 视觉 r1 实测前导墨迹 `38..58`(应 `44..68`)。所以**两轴都显式给**(规则 Q21)。
|
||||
let button = button(
|
||||
container(icon)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.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)))
|
||||
// 单行:页头高固定 56,长标题折行会顶穿页头并与 trailing 动作重叠(偏离 D-M79-5)
|
||||
.wrapping(Wrapping::None)
|
||||
.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)))
|
||||
.wrapping(Wrapping::None)
|
||||
.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)));
|
||||
}
|
||||
// 文本块吃掉剩余宽度并**裁切**:这一条同时解决两件事——
|
||||
// ① 标题与 trailing 之间必须有 Fill(M77 Q8 同款),② 单行超长标题切在动作按钮之前,
|
||||
// 不会重叠(`clip(true)` 是 iced 里唯一能裁子元素越界的方式;没有 ellipsis,见 D-M79-5)。
|
||||
row = row.push(container(texts).width(Length::Fill).clip(true));
|
||||
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))
|
||||
// 内容行(40 高的动作按钮 + 文本块)必须落在 56 页头的**垂直中线**上:
|
||||
// `Container` 的 vertical_alignment 默认 Top,只给 `width` 会让整行贴顶
|
||||
// (视觉 r1 实测前导图标墨迹 y=204..223,居中应是 209..227)。
|
||||
.align_y(Alignment::Center)
|
||||
.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,296 @@
|
||||
//! 导航项的公共类型与一行渲染(`drawer.rs` 的私有子模块:单文件 ≤400 行,AGENTS.md §3-1)。
|
||||
//!
|
||||
//! 拆出去的是「一项长什么样」(`NavItem` / `NavGroup` + 颜色求值 + 一行布局),
|
||||
//! `drawer.rs` 只留「容器长什么样」(宽度、圆角、分组标题、分隔线)。
|
||||
|
||||
use iced::widget::text::{LineHeight, Wrapping};
|
||||
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)))
|
||||
// 单行(项高固定 52/56):长分组名/实例名不许折行(偏离 D-M79-5)
|
||||
.wrapping(Wrapping::None)
|
||||
.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));
|
||||
// 标签要裁到轨宽:单行超长时切在 88 内,不会溢出到内容区(D-M79-5)
|
||||
stack = stack.push(
|
||||
container(line::<M>(
|
||||
ui,
|
||||
item.label,
|
||||
TypeRole::LabelMedium,
|
||||
colors.label,
|
||||
))
|
||||
.width(Length::Fixed(geometry.width))
|
||||
.align_x(Alignment::Center)
|
||||
.clip(true),
|
||||
);
|
||||
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,
|
||||
});
|
||||
// label 吃掉剩余宽度并裁切(单行 + 不压到药丸右内边距,D-M79-5)
|
||||
pill(Element::from(
|
||||
row.push(icon)
|
||||
.push(
|
||||
container(line::<M>(
|
||||
ui,
|
||||
item.label,
|
||||
TypeRole::LabelLarge,
|
||||
colors.label,
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.clip(true),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
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,333 @@
|
||||
//! 导航类家族的**几何**:能取令牌的取令牌,取不到的在此登记 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary 指示条圆角:`RoundedCornerShape(3dp)` 落在 3px 高的条上,与 `Full`(=1.5 半径)渲染结果相同。
|
||||
///
|
||||
/// Secondary 的 1px 分隔线**没有圆角**:A1 是一条直线;早先给 0.5 圆角,相邻 tab 的分段之间
|
||||
/// 因此留下 1px 亮缝(视觉 r1 P2-2 实测 `25..101 | 103..191 | 193..265`)。
|
||||
pub fn indicator_radius(&self) -> Radius {
|
||||
Radius::new(self.primary_bar / 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,131 @@
|
||||
//! `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.indicator_radius().top_left, 1.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,306 @@
|
||||
//! 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, Wrapping};
|
||||
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::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)))
|
||||
// **单行**:行高是固定值(44/60/76),`Text` 默认 `Wrapping::Word` 会把长实例名
|
||||
// 折成两行、把三行块顶穿。官方要的是单行 ellipsis,iced 没有 ellipsis(D19 先例)
|
||||
// → 单行 + 超长由外层 `clip(true)` 直接切(偏离 D-M79-5)。
|
||||
.wrapping(Wrapping::None)
|
||||
.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),
|
||||
));
|
||||
}
|
||||
// 文本块吃掉剩余宽度并**裁切**:单行超长时切在行内边距上,不会压到 trailing
|
||||
content = content.push(container(body).width(Length::Fill).clip(true));
|
||||
if let Some(kind) = item.trailing {
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! 导航家族的长文案取证块(D-M79-5):**单行 + 裁切**,页面真接长实例名时不许折行。
|
||||
//!
|
||||
//! 为什么不放在 `gallery/navigation.rs`:那个文件已经贴到 400 行红线(AGENTS.md §3-1),
|
||||
//! 而它属于 harness 单文件、scope 只授权一个路径。按 `selection::matrix` 的先例,把**取证块**
|
||||
//! 收进组件目录、对消息类型泛型(`pressed: impl Fn() -> M`),gallery 只做一次 `.map`。
|
||||
|
||||
use iced::widget::{column, container, 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 crate::theme::Ui;
|
||||
|
||||
use super::{
|
||||
app_bar, list_item, navigation, tabs, AppBar, AppBarAction, ListItem, NavGroup, NavItem,
|
||||
Navigation, Tab, Tabs,
|
||||
};
|
||||
|
||||
/// 一行小字图例。
|
||||
fn note<'a, M: 'a>(ui: &'a Ui, value: &'a str) -> Element<'a, M> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
text(value)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 取景板(`SurfaceContainerLow`):`Surface` 的页头与页面底色同色,要垫一层才量得到边界。
|
||||
fn plate<'a, M: 'a>(ui: &'a Ui, pad: f32, content: impl Into<Element<'a, M>>) -> Element<'a, M> {
|
||||
container(content)
|
||||
.padding(pad)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.color(Role::SurfaceContainerLow))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn icon(name: &str) -> IconKind {
|
||||
match name {
|
||||
"download" => IconKind::Download,
|
||||
"logs" => IconKind::Logs,
|
||||
"settings" => IconKind::Settings,
|
||||
_ => IconKind::Instances,
|
||||
}
|
||||
}
|
||||
|
||||
/// 长文案块(D-M79-5 的取证):单行 + 裁切——不折行、不顶穿 56 的页头、不压 trailing。
|
||||
/// 矩阵图全用短文案看不出这个坑,真实实例名(`All the Mods 10 - ATM10 …`)一接就中招。
|
||||
pub fn long_part<'a, M: Clone + 'a>(ui: &'a Ui, pressed: impl Fn() -> M) -> Element<'a, M> {
|
||||
const LONG: &str = "All the Mods 10 - ATM10 - 1.21.1 - NeoForge";
|
||||
|
||||
let bar = app_bar(
|
||||
ui,
|
||||
AppBar::new(LONG)
|
||||
.subtitle(LONG)
|
||||
.leading(AppBarAction::inert(icon("instances")))
|
||||
.trailing(AppBarAction::new(icon("settings"), pressed()))
|
||||
.trailing(AppBarAction::new(icon("logs"), pressed())),
|
||||
);
|
||||
|
||||
let row = |lines: usize| {
|
||||
let mut item = ListItem::new(LONG, pressed())
|
||||
.leading(icon("instances"))
|
||||
.trailing(icon("settings"));
|
||||
if lines >= 2 {
|
||||
item = item.supporting(LONG);
|
||||
}
|
||||
if lines >= 3 {
|
||||
item = item.overline(LONG);
|
||||
}
|
||||
container(list_item(ui, item.selected()))
|
||||
.width(Length::Fixed(ui.metrics().nav_drawer_width))
|
||||
};
|
||||
|
||||
let pane = navigation(
|
||||
ui,
|
||||
Navigation::drawer().group(
|
||||
NavGroup::titled(LONG)
|
||||
.item(NavItem::new(LONG, icon("instances"), pressed()).selected())
|
||||
.item(NavItem::new("Settings", icon("settings"), pressed())),
|
||||
),
|
||||
);
|
||||
|
||||
// Tabs:长标签不折行,tab 自己变宽(横向滚动兜住溢出)
|
||||
let tabs_row = tabs(
|
||||
ui,
|
||||
Tabs::primary()
|
||||
.tab(Tab::new(LONG, pressed()).selected())
|
||||
.tab(Tab::new("Settings", pressed())),
|
||||
);
|
||||
|
||||
column![
|
||||
note(
|
||||
ui,
|
||||
"长文案(D-M79-5):单行 + 裁切 —— 不折行、页头仍 56、不压 trailing"
|
||||
),
|
||||
plate(ui, ui.space(Space::X2), bar),
|
||||
column![row(1), row(2), row(3)].spacing(ui.space(Space::X4)),
|
||||
tabs_row,
|
||||
container(container(pane).height(Length::Fixed(ui.space(Space::X8) * 6.0)))
|
||||
.width(Length::Fixed(ui.metrics().nav_drawer_width)),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -1,35 +1,80 @@
|
||||
//! **导航类家族(桩)**:由后续 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 | — (本轮页头不带禁用动作) | — |
|
||||
//!
|
||||
//! ## Q21:要给居中就**两轴都显式给**(第三轮 review 的规则化)
|
||||
//!
|
||||
//! `Container` 的 `horizontal_alignment` 默认 `Left`、`vertical_alignment` 默认 `Top`
|
||||
//! (`iced_widget-0.14.2/src/container.rs`)。只给 `align_y` 会让内容贴左缘——AppBar 的 40×40
|
||||
//! 动作按钮里 24px 图标曾因此左内缩 4 / 右内缩 20(视觉 r1 实测前导墨迹 `38..58`,应 `44..68`),
|
||||
//! 而且 hover 的圆形状态层会露在图标一侧。
|
||||
//!
|
||||
//! 本目录的复查结论(逐处):
|
||||
//!
|
||||
//! | 位置 | 需要 | 写法 |
|
||||
//! |---|---|---|
|
||||
//! | `app_bar::action` 命中区里的图标 | 两轴居中 | `align_x` + `align_y` |
|
||||
//! | `app_bar` 页头内容行 | 垂直居中(水平由 `Fill` 决定) | `.align_y(Center)` |
|
||||
//! | `tabs::tab_cell` 的内容 | 两轴居中 | `align_x` + `align_y` |
|
||||
//! | `drawer/item` 的药丸 | 两轴居中 | `align_x` + `align_y` |
|
||||
//! | `list_item` 的文本块 / `drawer` 的分组标题 | **左上起排**(`Start` 是对的,不需要居中) | 不设对齐,靠 `padding` 定起点 |
|
||||
//!
|
||||
//! 回归钉子:`navigation::probe` 的 appbar 断言量「左内缩 == 右内缩 ±2」——`align_x` 再漏一次就红。
|
||||
//!
|
||||
//! ## 单行约束(偏离 D-M79-5)
|
||||
//!
|
||||
//! 四件的文本一律 `Wrapping::None` + 外层 `clip(true)`:容器高度都是固定值(44/60/76、48/64、56),
|
||||
//! `Text` 默认 `Wrapping::Word` 会把长实例名折成 2~3 行,顶穿页头、压住 trailing。官方要的是
|
||||
//! **单行 ellipsis**,iced 没有 ellipsis(`inputs/menu.rs` 的 D19 先例)→ 我们单行 + 超长直接切。
|
||||
//! 取证:`ULL_NAV_PART=long` 出图 + `navigation::probe::long` 量「行高不变 / 页头仍 56 / 标题不压动作」。
|
||||
//!
|
||||
//! ## 令牌缺口(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;
|
||||
pub mod matrix;
|
||||
#[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,393 @@
|
||||
//! 渲染层几何断言:**对着真截图量像素**(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 divider;
|
||||
mod long;
|
||||
mod scan;
|
||||
use scan::{rgb, ui as ui_of, BBox, Rgb, Shot};
|
||||
|
||||
/// 有这张图就读,没有就 `None`(深色档的可选覆盖用)。
|
||||
fn shot_opt(dir: &Path, part: &str, flavor: SchemeFlavor) -> Option<Shot> {
|
||||
let slug = match flavor {
|
||||
SchemeFlavor::Light => "light",
|
||||
SchemeFlavor::Dark => "dark",
|
||||
};
|
||||
let path = dir.join(format!("{part}-{slug}.png"));
|
||||
path.exists().then(|| Shot::load(&path))
|
||||
}
|
||||
|
||||
/// `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_of(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_of(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));
|
||||
}
|
||||
// 6.6:Secondary 的分隔线必须是**一条连续直线**(逐 tab 分段画会在相邻段之间留缝)。
|
||||
// 判据与锚定规则全在 `probe/divider.rs`(含「无论红绿都打印锚定行」)。
|
||||
divider::check(&shot, &ui, "light");
|
||||
if let Some(dark) = shot_opt(&dir, "tabs", SchemeFlavor::Dark) {
|
||||
divider::check(&dark, &ui_of(SchemeFlavor::Dark), "dark");
|
||||
}
|
||||
|
||||
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_of(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_of(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)"
|
||||
);
|
||||
|
||||
// Q21:动作图标必须在 40×40 命中区正中出现(`Container` 默认 Left/Top,
|
||||
// 只给 `align_y` 会贴左缘——视觉 r1 实测前导墨迹 `38..58`,应 `44..68`)。
|
||||
// 判据:左内缩(bar.left → 前导墨迹左缘)== 右内缩(尾随墨迹右缘 → bar.right),±2px。
|
||||
let bars = shot
|
||||
.blocks(resting)
|
||||
.into_iter()
|
||||
.filter(|b| b.width() > 300 && (54..=58).contains(&b.height()))
|
||||
.collect::<Vec<_>>();
|
||||
let bar = bars[0];
|
||||
let leading_ink = rgb(ui.color(Role::OnSurface));
|
||||
let trailing_ink = rgb(ui.color(Role::OnSurfaceVariant));
|
||||
let leading = shot
|
||||
.band_extent(leading_ink, bar.top, bar.bottom, bar.left, bar.left + 48)
|
||||
.expect("页头左侧应有前导图标墨迹");
|
||||
let trailing = shot
|
||||
.band_extent(
|
||||
trailing_ink,
|
||||
bar.top,
|
||||
bar.bottom,
|
||||
bar.right.saturating_sub(48),
|
||||
bar.right,
|
||||
)
|
||||
.expect("页头右侧应有尾随图标墨迹");
|
||||
let left_inset = leading.0 as i32 - bar.left as i32;
|
||||
let right_inset = bar.right as i32 - trailing.1 as i32;
|
||||
assert!(
|
||||
(left_inset - right_inset).abs() <= 2,
|
||||
"动作图标不在命中区正中:左内缩 {left_inset} / 右内缩 {right_inset}(Q21:`align_x` 漏给会差 8px)"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"[navigation::probe] appbar: 高度 {measured:?},图标内缩 左 {left_inset} / 右 {right_inset}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! 分隔线守卫(6.6):**Secondary 的分隔线必须是一条连续直线**。
|
||||
//!
|
||||
//! 逐 tab 分段画会在相邻段之间留缝(视觉 r1 P2-2 实测 `25..101 | 103..191 | 193..265`,
|
||||
//! 交界像素偏亮)。这条守卫被复审三轮打过三次,每次都是「假绿」,所以下面把**每个判据
|
||||
//! 挡的是哪一次假通过**写清楚——改之前先读一遍:
|
||||
//!
|
||||
//! | 判据 | 挡的假通过 |
|
||||
//! |---|---|
|
||||
//! | ① 锚定「细 + 近乎实心 + 薄 + 低对比 + 跨度 ≥200」的一行(见 `scan::Shot::find_thin_line`) | 五态行的 40px 高底色带、`Primary` 指示条顶替分隔线 |
|
||||
//! | ② 线色必须是 `Surface → SurfaceVariant` 的插值(覆盖率 30%~105%) | 更靠上的同类细线顶替:`OutlineVariant` 组间线(t 出界)、hover 8% 带(三通道 t 散开) |
|
||||
//! | ③ 参考色取**该行墨迹众数色**(见 `scan::Shot::line_segments`) | 缺口恰落在跨度中点时,参考色退化成页面底色 → 恒 1 段 |
|
||||
//! | ④ 在该行**整段墨迹跨度**上数段(不是某次 run 的窗口) | 缺口在窗口外看不见、在窗口内被「跳过去重新起跑」滑过 |
|
||||
//!
|
||||
//! ## 锚定行**无条件打印**
|
||||
//!
|
||||
//! 不管红绿都往 stderr 打一行 `[probe] divider anchored at y=… span=x..x color=(r,g,b) segments=N`。
|
||||
//! 之前绿的时候什么都不说,于是「锚错行」是静默的——不看断言在量什么,等于没有断言。
|
||||
//!
|
||||
//! ## 深浅两档
|
||||
//!
|
||||
//! 浅色图必跑;深色图(`tabs-dark.png`)**存在就跑**(深色的 divider 对比更低,更值得测)。
|
||||
//! 没有深色图时不算失败,但会在输出里留一行 `dark 档跳过`。
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::scan::{rgb, Shot};
|
||||
|
||||
/// 在 `shot` 上验证分隔线是一条直线;`label` 只用于日志(`light` / `dark`)。
|
||||
pub(super) fn check(shot: &Shot, ui: &Ui, label: &str) {
|
||||
let surface = rgb(ui.color(Role::Surface));
|
||||
let variant = rgb(ui.color(Role::SurfaceVariant));
|
||||
let (y, x0, x1, color) = shot
|
||||
.find_thin_line(surface, variant, 200, 120)
|
||||
.unwrap_or_else(|| panic!("[{label}] 找不到 Secondary 那一行细线(锚定失败)"));
|
||||
let segments = shot.line_segments(y, x0, x1);
|
||||
eprintln!(
|
||||
"[probe] divider anchored at y={y} span=x{x0}..{x1} color={color:?} segments={segments} \
|
||||
({label}, {shot_width}x{shot_height})",
|
||||
shot_width = shot.width,
|
||||
shot_height = shot.height,
|
||||
);
|
||||
assert_eq!(
|
||||
segments, 1,
|
||||
"[{label}] Secondary 分隔线应是一条直线,实测 {segments} 段(量到 y={y}, x{x0}..{x1})——\
|
||||
分段画会在交界处留缝"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! 长文案的渲染层断言(**D-M79-5**:我们的单行约束是否真的成立)。
|
||||
//!
|
||||
//! 矩阵图全用短文案("Instances" / "Instance details"),`Text` 默认 `Wrapping::Word` 的坑
|
||||
//! 在图上根本看不出来——页面一接真实实例名(`All the Mods 10 - ATM10 - 1.21.1 - NeoForge`)
|
||||
//! 就会折成 2~3 行、顶穿 56 的页头、压住 trailing 动作。所以这里用 `ULL_NAV_PART=long`
|
||||
//! 那张图单独量三件事:
|
||||
//!
|
||||
//! | 断言 | 抓的错误 |
|
||||
//! |---|---|
|
||||
//! | ListItem 三行高仍 44/60/76 | 长文本折行把行撑高 / 被固定行高裁掉 |
|
||||
//! | AppBar 仍 56 高 | 标题折行顶穿页头 |
|
||||
//! | 标题墨迹右缘 < trailing 图标左缘 | 长标题覆盖到动作按钮上 |
|
||||
//!
|
||||
//! 跑法见 `navigation/probe.rs` 的模块文档(`ULL_NAV_DIR=target/m79`,图名 `long-light.png`)。
|
||||
|
||||
use unlucky_design::color::{Role, SchemeFlavor};
|
||||
|
||||
use super::scan::{rgb, ui};
|
||||
use super::{shot, shot_dir};
|
||||
|
||||
#[test]
|
||||
fn long_text_stays_single_line() {
|
||||
let Some(dir) = shot_dir() else {
|
||||
eprintln!(
|
||||
"[navigation::probe::long] SKIPPED:未设置 ULL_NAV_DIR(需要先出 long-light.png)"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let shot = shot(&dir, "long", SchemeFlavor::Light);
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
|
||||
// ---- ① ListItem:长实例名不折行 → 行高仍是 44 / 60 / 76 ----
|
||||
let selected = rgb(ui.color(Role::SecondaryContainer));
|
||||
let mut rows = shot
|
||||
.blocks(selected)
|
||||
.into_iter()
|
||||
.filter(|b| (238..=242).contains(&b.width()) && b.height() > 30)
|
||||
.collect::<Vec<_>>();
|
||||
rows.sort_by_key(|b| b.top);
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
3,
|
||||
"长文案块应有 3 条 ListItem(实 {})",
|
||||
rows.len()
|
||||
);
|
||||
let heights = rows.iter().map(|b| b.height()).collect::<Vec<_>>();
|
||||
for (measured, expected) in heights.iter().zip([44usize, 60, 76]) {
|
||||
assert!(
|
||||
(expected - 1..=expected).contains(measured),
|
||||
"长文案把行高撑成 {heights:?}(应为 44/60/76 ±1)——说明折行了"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ② AppBar:长标题不顶穿页头(仍 56 高)----
|
||||
let bar = shot
|
||||
.blocks(rgb(ui.color(Role::Surface)))
|
||||
.into_iter()
|
||||
.find(|b| b.width() > 300 && (54..=58).contains(&b.height()))
|
||||
.expect("长文案块应有一条 56 高的页头");
|
||||
assert!(
|
||||
(55..=57).contains(&bar.height()),
|
||||
"长标题把页头撑成 {} 高(应 56)——说明折行了",
|
||||
bar.height()
|
||||
);
|
||||
|
||||
// ---- ③ 标题不许压到 trailing 动作上 ----
|
||||
let title_ink = shot
|
||||
.band_extent(
|
||||
rgb(ui.color(Role::OnSurface)),
|
||||
bar.top,
|
||||
bar.bottom,
|
||||
bar.left + 40,
|
||||
bar.right.saturating_sub(90),
|
||||
)
|
||||
.expect("页头里应有标题墨迹");
|
||||
let trailing = shot
|
||||
.band_extent(
|
||||
rgb(ui.color(Role::OnSurfaceVariant)),
|
||||
bar.top,
|
||||
bar.bottom,
|
||||
bar.right.saturating_sub(90),
|
||||
bar.right,
|
||||
)
|
||||
.expect("页头右侧应有 trailing 图标墨迹");
|
||||
assert!(
|
||||
title_ink.1 + 2 < trailing.0,
|
||||
"长标题墨迹右缘 {} 压到 trailing 图标左缘 {} 上了(应被裁在文本区里)",
|
||||
title_ink.1,
|
||||
trailing.0
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"[navigation::probe] long: 行高 {heights:?},页头 {} 高,标题右缘 {} < trailing {}",
|
||||
bar.height(),
|
||||
title_ink.1,
|
||||
trailing.0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! `probe` 的读图器(`probe.rs` 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
|
||||
//!
|
||||
//! 只做三件事:读 PNG、找同色的连通块(`BBox`)、量一条横带里某颜色的墨迹范围
|
||||
//! 与「一条细线」。判据在 `probe.rs` / `probe/divider.rs` / `probe/long.rs`。
|
||||
//!
|
||||
//! ## 已知边界(分隔线守卫,写在这里供以后改的人查)
|
||||
//!
|
||||
//! 1. **≤25% 覆盖的淡线找不到** → 表现是 **loud red**(`find_thin_line` 返回 `None`,
|
||||
//! `divider::check` 直接 panic「锚定失败」),**不是假绿**。原因:`find_thin_line` 的
|
||||
//! `TOL=6` 是单通道可辨差下限,40% 覆盖行实测 `(9,6,4)` 刚好在线上,25% 覆盖行只有
|
||||
//! `(6,4,2)`,会落到判据外——但相邻那行是满覆盖(`(25,17,10)`),所以真实渲染的画法下
|
||||
//! 行锚定不会丢;只有「整条线都画在半像素上」才会既找不到又看起来像没画。
|
||||
//! 2. **身份校验绑定了 `SurfaceVariant`**:`divider::check` 用
|
||||
//! `Surface → SurfaceVariant` 的插值做身份校验。若哪天 Secondary 分隔线换了角色色
|
||||
//! (比如改回 `OutlineVariant`),这里会 loud red——届时改 `divider.rs` 里那一行 `Role`。
|
||||
//! 3. **只认「一条横线」**:竖向分隔线(本项目不用)与圆角/虚线画法不在判据内。
|
||||
//! 4. **深浅两档**:浅色图必跑;深色图存在就跑(`tabs-dark.png`),深色 divider 对比更低
|
||||
//! (实测线色 `(46,60,70)`,与深色 Surface 的差比浅色档小),更值得测。
|
||||
|
||||
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 {
|
||||
// **每个通道**都在容差内;`differs` 是它的否定(任一通道超差即「不同」)
|
||||
!differs(pixel, expected, TOLERANCE)
|
||||
}
|
||||
|
||||
/// 两个颜色在**每个通道**上都不超过 `tol`。
|
||||
pub(super) fn differs(pixel: Rgb, expected: Rgb, tol: u8) -> bool {
|
||||
pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > tol)
|
||||
}
|
||||
|
||||
/// 一个外接框(闭区间)。
|
||||
#[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)
|
||||
}
|
||||
|
||||
/// 矩形里某颜色墨迹的左右范围(量图标/文字位置用)。
|
||||
pub(super) fn band_extent(
|
||||
&self,
|
||||
color: Rgb,
|
||||
y0: usize,
|
||||
y1: usize,
|
||||
x0: usize,
|
||||
x1: 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) {
|
||||
if matches(self.at(x, y), color) {
|
||||
span = Some(match span {
|
||||
Some((l, r)) => (l.min(x), r.max(x)),
|
||||
None => (x, x),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
span
|
||||
}
|
||||
|
||||
/// 找「一条**细、低对比、近乎实心**的长横线」,返回 `(y, x0, x1)`:
|
||||
/// `y` 是线最实的那一行,`x0..x1` 是**该行整段墨迹的跨度**(不是某一次连续 run 的窗口)。
|
||||
///
|
||||
/// 四条判据,每条都对应一次真实的假通过(视觉 r1 P2-2 复核时被点出来的):
|
||||
///
|
||||
/// 1. **跨度 ≥ `min_len`**(200):`Secondary` 分隔线整行 242px;标题/标签行凑不满,能跳过它们。
|
||||
/// 2. **近乎实心**(墨迹数 ≥ 跨度 × 4/5):逐 tab 分段时中间是真缺口,但只有 1~2px,仍算实心
|
||||
/// ——**必须先能找到这一行**,再由 [`Shot::line_segments`] 把缺口数成多段。
|
||||
/// (早先「凑满 150px 就 return,然后在窗口里数段」的写法会滑过缺口,永远数到 1。)
|
||||
/// 3. **薄**(跨中点纵向墨迹 ≤ 3 行):五态行里的 hover 底色是 40px 高的带,被这条排除
|
||||
/// (早先的写法会把那条 224px 的带选中,量到完全不同的元素)。
|
||||
/// 4. **低对比**(线色与底色的单通道最大差 ≤ `max_contrast`):`Primary` 指示条是高饱和色,被排除。
|
||||
///
|
||||
/// `TOL` 的口径:**单通道**最大可辨差,取 6。40% 覆盖的抗锯齿行实测单通道差 `(9,6,4)`,
|
||||
/// 6 能看见它、又挡住 ±3 的渲染噪声;覆盖再低的行会落到判据外,但**相邻那行是满覆盖**
|
||||
/// (实测线色差 25/17/10),所以行的锚定不会丢。
|
||||
pub(super) fn find_thin_line(
|
||||
&self,
|
||||
background: Rgb,
|
||||
expected: Rgb,
|
||||
min_len: usize,
|
||||
max_contrast: u8,
|
||||
) -> Option<(usize, usize, usize, Rgb)> {
|
||||
const TOL: u8 = 6;
|
||||
let is_ink = |pixel: Rgb| differs(pixel, background, TOL);
|
||||
let contrast = |pixel: Rgb| {
|
||||
pixel
|
||||
.iter()
|
||||
.zip(background)
|
||||
.map(|(p, q)| p.abs_diff(q))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
for y in 0..self.height {
|
||||
let ink: Vec<usize> = (0..self.width).filter(|x| is_ink(self.at(*x, y))).collect();
|
||||
let (Some(&first), Some(&last)) = (ink.first(), ink.last()) else {
|
||||
continue;
|
||||
};
|
||||
let len = last - first + 1;
|
||||
if len < min_len || ink.len() * 5 < len * 4 {
|
||||
continue;
|
||||
}
|
||||
// 薄:跨中点纵向墨迹 ≤ 3 行
|
||||
let mid = (first + last) / 2;
|
||||
let mut top = y;
|
||||
while top > 0 && is_ink(self.at(mid, top - 1)) {
|
||||
top -= 1;
|
||||
}
|
||||
let mut bottom = y;
|
||||
while bottom + 1 < self.height && is_ink(self.at(mid, bottom + 1)) {
|
||||
bottom += 1;
|
||||
}
|
||||
if bottom - top + 1 > 3 {
|
||||
continue;
|
||||
}
|
||||
// 低对比:取这条细带里最实的一行(满覆盖),跨段与线色都按那一行算
|
||||
let best = (top..=bottom)
|
||||
.max_by_key(|candidate| {
|
||||
(first..=last)
|
||||
.filter(|x| is_ink(self.at(*x, *candidate)))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(y);
|
||||
let line = self.modal_color(best, first, last);
|
||||
let c = contrast(line);
|
||||
if c > max_contrast || c <= TOL {
|
||||
continue;
|
||||
}
|
||||
// **身份校验**:线色必须落在 `底色 → 预期角色色` 的插值线上(覆盖率 30%~105%)。
|
||||
// 只靠「跨度/薄/低对比」挡不住「更靠上的另一条同类细线」(复审 r3 的
|
||||
// `decoy_outline_gap` / `decoy_hover_gap` 两个反例都因此锚错行)。
|
||||
let Some(coverage) = Self::blend_coverage(line, background, expected) else {
|
||||
eprintln!(
|
||||
"[probe] divider skip y={best} color={line:?}(不是预期角色色的插值,拒绝)"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if !(0.30..=1.05).contains(&coverage) {
|
||||
eprintln!(
|
||||
"[probe] divider skip y={best} color={line:?}(覆盖率 {coverage:.2} 出界,拒绝)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return Some((best, first, last, line));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `y` 行 `[x0, x1]` 上「按该行**墨迹众数色**」数的连通段数(分隔线断没断)。
|
||||
///
|
||||
/// 参考色为什么不能取「跨度中点那一个像素」:缺口恰落在中点时,参考色变成页面底色,
|
||||
/// 数出来的是「底色有几段」→ 恒为 1 → **假绿**(复审 r3 的 `gap_at_mid` 反例)。
|
||||
/// 众数色在整段跨度里天然是线自己的颜色(242px 的线 vs ≤4px 的缺口)。
|
||||
pub(super) fn line_segments(&self, y: usize, x0: usize, x1: usize) -> usize {
|
||||
let line = self.modal_color(y, x0, x1);
|
||||
let mut segments = 0;
|
||||
let mut in_run = false;
|
||||
for x in x0..=x1 {
|
||||
let on = matches(self.at(x, y), line);
|
||||
if on && !in_run {
|
||||
segments += 1;
|
||||
in_run = true;
|
||||
} else if !on {
|
||||
in_run = false;
|
||||
}
|
||||
}
|
||||
segments
|
||||
}
|
||||
|
||||
/// `y` 行 `[x0, x1]` 里出现次数最多的颜色(分隔线的线色)。
|
||||
pub(super) fn modal_color(&self, y: usize, x0: usize, x1: usize) -> Rgb {
|
||||
let mut counts: std::collections::HashMap<Rgb, usize> = std::collections::HashMap::new();
|
||||
for x in x0..=x1.min(self.width - 1) {
|
||||
*counts.entry(self.at(x, y)).or_insert(0) += 1;
|
||||
}
|
||||
counts
|
||||
.into_iter()
|
||||
.max_by_key(|(_, n)| *n)
|
||||
.map(|(c, _)| c)
|
||||
.unwrap_or(self.at(x0.min(self.width - 1), y))
|
||||
}
|
||||
|
||||
/// `pixel` 是否落在 `from → to` 的插值线上(覆盖率 t),返回三个通道一致时的平均 t。
|
||||
///
|
||||
/// 用来给「这条线是不是某个角色色」做**身份校验**:比「单通道差 ≤N」稳得多——
|
||||
/// 实测 `OutlineVariant (200,212,224)` → t=(1.84,2.24,3.1) 出界;
|
||||
/// hover 8% `(228,233,238)` → t=(0.72,1.0,1.7) 三通道散开 0.98;两者都被拒。
|
||||
/// 而真分隔线满覆盖 `(221,233,245)` → t=(1,1,1);40% 覆盖的抗锯齿行 → t≈(0.36,0.35,0.40) 被接受。
|
||||
///
|
||||
/// 只统计 `|to - from| > 4` 的通道(差太小的通道不携带信息,噪声会把 t 带偏)。
|
||||
pub(super) fn blend_coverage(pixel: Rgb, from: Rgb, to: Rgb) -> Option<f32> {
|
||||
let mut ts = Vec::new();
|
||||
for ((p, f), t) in pixel.iter().zip(from).zip(to) {
|
||||
let delta = f32::from(t) - f32::from(f);
|
||||
if delta.abs() <= 4.0 {
|
||||
continue;
|
||||
}
|
||||
ts.push((f32::from(*p) - f32::from(f)) / delta);
|
||||
}
|
||||
if ts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let max = ts.iter().cloned().fold(f32::MIN, f32::max);
|
||||
let min = ts.iter().cloned().fold(f32::MAX, f32::min);
|
||||
if max - min > 0.25 {
|
||||
return None;
|
||||
}
|
||||
Some(ts.iter().sum::<f32>() / ts.len() as f32)
|
||||
}
|
||||
|
||||
/// 矩形内「与底色不同」的像素的左右范围(量文本墨迹用)。
|
||||
///
|
||||
/// 为什么不按精确文字色找:小字号抗锯齿后几乎没有像素落在**精确**色上(容差 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,359 @@
|
||||
//! 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, Wrapping};
|
||||
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 指示条的颜色(只有选中项有;禁用项降档,否则「点不动却亮着」)。
|
||||
fn bar_color(ui: &Ui, state: Interaction, selected: bool) -> Option<Color> {
|
||||
if !selected {
|
||||
return None;
|
||||
}
|
||||
if state.is_disabled() {
|
||||
return Some(interaction::disabled_content_over_role(
|
||||
ui,
|
||||
Role::OnSurface,
|
||||
false,
|
||||
));
|
||||
}
|
||||
Some(ui.color(Role::Primary))
|
||||
}
|
||||
|
||||
/// 6.6:Secondary 的分隔线色 = **`SurfaceVariant`**(不是 `OutlineVariant`——那是 §22 的分隔线)。
|
||||
///
|
||||
/// 色值只在这里出现一次:整行那条线与逐 tab 的实现都从这里取。
|
||||
fn secondary_line_color(ui: &Ui) -> Color {
|
||||
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));
|
||||
}
|
||||
|
||||
let body: Element<'a, M> = if primary {
|
||||
row.into()
|
||||
} else {
|
||||
// A1 的 Secondary 分隔线是**整行一条直线**,不是每个 tab 一段:
|
||||
// 逐段画会在相邻段之间留缝(视觉 r1 P2-2)。这里用 `Stack` 把一条 Fill 线压在整行底下,
|
||||
// 底座是 tab 行本身(Shrink → 内容宽),所以线长 = tab 行总宽,横向滚动时跟着走。
|
||||
let line = container(space::vertical())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(geometry.secondary_bar))
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(secondary_line_color(ui))),
|
||||
..container::Style::default()
|
||||
});
|
||||
Stack::new()
|
||||
.push(container(row))
|
||||
.push(
|
||||
container(line)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_y(Alignment::End),
|
||||
)
|
||||
.into()
|
||||
};
|
||||
|
||||
scrollable(body)
|
||||
.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)))
|
||||
// 单行(容器高固定 48/64):长标签让 tab 变宽 + 横向滚动,不折行(偏离 D-M79-5)
|
||||
.wrapping(Wrapping::None)
|
||||
.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))
|
||||
// Q21:要给居中就两轴都显式给(`Container` 默认 Left/Top)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding {
|
||||
top: 0.0,
|
||||
right: geometry.pad,
|
||||
bottom: bar_height,
|
||||
left: geometry.pad,
|
||||
});
|
||||
|
||||
// Primary:逐 tab 画条(要跟随该 tab 的宽度);Secondary:整行一条,在 `tabs()` 里画
|
||||
let bar = if primary {
|
||||
bar_color(ui, state, selected)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let radius = geometry.indicator_radius();
|
||||
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,150 @@
|
||||
//! `tabs` 的单元测试(split 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
|
||||
|
||||
use super::*;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
use super::super::super::interaction;
|
||||
|
||||
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:Primary 只有选中项有 3px `Primary` 条;禁用项降档。
|
||||
#[test]
|
||||
fn primary_bar_only_on_the_selected_tab() {
|
||||
let ui = ui();
|
||||
assert_eq!(bar_color(&ui, Interaction::Enabled, false), None);
|
||||
assert_eq!(
|
||||
bar_color(&ui, Interaction::Enabled, true),
|
||||
Some(ui.color(Role::Primary))
|
||||
);
|
||||
assert_eq!(
|
||||
bar_color(&ui, Interaction::Disabled, true),
|
||||
Some(interaction::disabled_content_over_role(
|
||||
&ui,
|
||||
Role::OnSurface,
|
||||
false
|
||||
)),
|
||||
"禁用项「点不动却亮着」是错的"
|
||||
);
|
||||
}
|
||||
|
||||
/// 6.6:Secondary 的分隔线整行一条,颜色 `SurfaceVariant`(不是 §22 的 `OutlineVariant`)。
|
||||
#[test]
|
||||
fn secondary_line_is_surface_variant() {
|
||||
let ui = ui();
|
||||
assert_eq!(secondary_line_color(&ui), ui.color(Role::SurfaceVariant));
|
||||
assert_ne!(
|
||||
secondary_line_color(&ui),
|
||||
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", ())),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user