四件组件 + gallery 状态矩阵 + 渲染层几何断言,全部只落在 `components/navigation/**` 与 `gallery/navigation.rs`(不改 mod.rs / interaction / layout)。 - ListItem(12b §4):一行/两行/三行 44/60/76(A1 56/72/88,登记偏离)、leading/trailing 槽位、 hover/focus/pressed 只叠状态层(内容色不变)、禁用是修饰器且内容角色取 A1 的 `OnSurface`@38%。 - Tabs(12b §6):Primary 3px 指示条(用 `Stack` 分层让 Fill 条拿到「该 tab 的宽」)、 Secondary 1px `SurfaceVariant` 分隔线、带图标时容器 64、横向滚动;选中色按变体分(Primary / OnSurface)。 - NavigationDrawer(15 §21 + D33):**同一个容器两个宽度** —— rail 88 ⇄ drawer 240; 药丸从容器宽派生(56×32 / 216×56,左右各缩 16 / 12,A1 `ItemPadding`); 分组标题 TitleSmall、组间 1px OutlineVariant;drawer 项高 56(G24)。 - AppBar small(12b §16):页头 56(A1 64,登记偏离)、TitleLarge emphasized + LabelMedium 副标题、 leading/trailing 槽位、滚动只换 `SurfaceContainer` + L2(A1 无 OnScroll…Height)。 - gallery:`ULL_NAV_PART=list|tabs|drawer|appbar|all` 分块出图;四块矩阵(ListItem 三行高 × 三态、 Tabs 三个选中位置 + 五态、rail/drawer 同尺度并排、AppBar 静息/滚动)。 - `navigation/probe.rs`:读真截图量像素的 4 条断言(行高 44/60/76、禁用 OnSurface@38%、 指示条宽 = 标签墨迹 + 2×16、rail 88 / drawer 240 与药丸内缩 16/12、AppBar 56), 已用两次变异(行高改 56/72/88;drawer 内缩改 16)验证能被打红。 门禁:fmt ✓ · clippy -p unlucky-app-iced --all-targets -D warnings ✓ · test -p unlucky-app-iced 195 passed ✓
352 lines
13 KiB
Rust
352 lines
13 KiB
Rust
//! gallery 里的导航类小节:四块状态矩阵(**断言在 `navigation::probe`,不在这里**)。
|
||
//!
|
||
//! ## 为什么再切一层 `ULL_NAV_PART`
|
||
//!
|
||
//! `ULL_GALLERY_SECTION=navigation` 只解决「别的家族」;本家族自己就有四块矩阵,
|
||
//! 竖排起来比 760 还高,一屏只拍得到第一块。所以这里加**块选择器**(契约 §9.2 的同一套规则:
|
||
//! 环境变量 + 默认向后兼容 + 未识别值回落并打 stderr):
|
||
//!
|
||
//! ```text
|
||
//! ULL_GALLERY_SECTION=navigation ULL_NAV_PART=list # 只渲染 ListItem 矩阵
|
||
//! ULL_NAV_PART=tabs | drawer | appbar | all(默认 all)
|
||
//! ```
|
||
//!
|
||
//! 出图命令(worktree 根目录):
|
||
//!
|
||
//! ```text
|
||
//! ULL_GALLERY_SECTION=navigation ULL_NAV_PART=list ./target/debug/unlucky-launcher-iced.exe \
|
||
//! --software-renderer --screen=gallery --screenshot target/m79/list-light.png --screenshot-theme=light
|
||
//! ULL_NAV_DIR=target/m79 cargo test -p unlucky-app-iced navigation::probe
|
||
//! ```
|
||
//!
|
||
//! ## 矩阵形状
|
||
//!
|
||
//! ```text
|
||
//! ListItem 行 = 一 / 两 / 三行(44 / 60 / 76) 列 = 选中 / 未选中 / 禁用
|
||
//! Tabs 两行 Primary(短文案 / 长文案各选中一次)+ 一行 Secondary + 一行五态
|
||
//! Drawer rail 88 与 drawer 240 **同尺度并排**(D33:同一容器两个宽度),各 3 项、第 2 项选中
|
||
//! AppBar 静息 / 滚动两态(56 高;滚动换容器色 + L2)
|
||
//! ```
|
||
//!
|
||
//! 「画在取景板(`SurfaceContainerLow`)上」不是装饰:`Surface` 的页头/导航与页面底色同色,
|
||
//! 不垫一层板子,截图里量不到边界(probe 就是靠这块板的 bbox 量 88 / 240 的)。
|
||
|
||
use iced::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 crate::theme::Ui;
|
||
|
||
use super::super::interaction::Interaction;
|
||
use super::super::navigation::{
|
||
app_bar, list_item, navigation, tabs, AppBar, AppBarAction, ListItem, NavGroup, NavItem,
|
||
NavLayout, Navigation, Tab, Tabs, TabsVariant,
|
||
};
|
||
use super::{heading, DemoMessage};
|
||
|
||
/// 块选择器的环境变量名(语义见模块文档)。
|
||
pub const PART_ENV: &str = "ULL_NAV_PART";
|
||
|
||
/// 本次渲染哪一块(`all` = 四块都渲染,默认)。
|
||
fn part() -> String {
|
||
let Ok(raw) = std::env::var(PART_ENV) else {
|
||
return "all".to_owned();
|
||
};
|
||
let name = raw.trim().to_ascii_lowercase();
|
||
match name.as_str() {
|
||
"" | "all" | "list" | "tabs" | "drawer" | "appbar" => name,
|
||
_ => {
|
||
eprintln!("[gallery] {PART_ENV}={raw:?} 不是已知块(all|list|tabs|drawer|appbar),已回落 all");
|
||
"all".to_owned()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 演示消息(组件只认泛型,映射在 gallery 侧落地)。
|
||
fn pressed() -> DemoMessage {
|
||
DemoMessage::Pressed("navigation")
|
||
}
|
||
|
||
fn icon(name: &str) -> IconKind {
|
||
match name {
|
||
"download" => IconKind::Download,
|
||
"logs" => IconKind::Logs,
|
||
"settings" => IconKind::Settings,
|
||
_ => IconKind::Instances,
|
||
}
|
||
}
|
||
|
||
/// 取景板:`SurfaceContainerLow` 底(`Surface` 的页头/导航要靠它才有边界)。
|
||
fn plate<'a>(
|
||
ui: &'a Ui,
|
||
pad: f32,
|
||
content: impl Into<Element<'a, DemoMessage>>,
|
||
) -> Element<'a, DemoMessage> {
|
||
container(content)
|
||
.padding(pad)
|
||
.style(move |_theme| container::Style {
|
||
background: Some(Background::Color(ui.color(Role::SurfaceContainerLow))),
|
||
..container::Style::default()
|
||
})
|
||
.into()
|
||
}
|
||
|
||
/// 一行小字图例。
|
||
fn note<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, DemoMessage> {
|
||
let style = ui.style(TypeRole::BodySmall);
|
||
text(value)
|
||
.size(style.size_px)
|
||
.font(ui.font_of(style))
|
||
.color(ui.color(Role::OnSurfaceVariant))
|
||
.into()
|
||
}
|
||
|
||
/// 导航类家族的小节。
|
||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||
let picked = part();
|
||
let mut body = column![heading(
|
||
ui,
|
||
"navigation · list item / tabs / drawer / app bar",
|
||
"12b §4/§6/§16 + 15 §21:ListItem 44/60/76 · Tabs 48 + 指示条 3 · \
|
||
Drawer = rail 88 ⇄ drawer 240(同一容器)· AppBar 56 + 滚动换色"
|
||
)]
|
||
.spacing(ui.space(Space::X8));
|
||
|
||
if picked == "all" || picked == "list" {
|
||
body = body.push(list_part(ui));
|
||
}
|
||
if picked == "all" || picked == "tabs" {
|
||
body = body.push(tabs_part(ui));
|
||
}
|
||
if picked == "all" || picked == "drawer" {
|
||
body = body.push(drawer_part(ui));
|
||
}
|
||
if picked == "all" || picked == "appbar" {
|
||
body = body.push(app_bar_part(ui));
|
||
}
|
||
body.into()
|
||
}
|
||
|
||
/// 一个列表格(宽 = `nav_drawer_width` 240:够放下 icon + 两行文本,且与 drawer 药丸 216 可区分)。
|
||
fn list_cell<'a>(ui: &'a Ui, item: ListItem<'a, DemoMessage>) -> Element<'a, DemoMessage> {
|
||
container(list_item(ui, item))
|
||
.width(Length::Fixed(ui.metrics().nav_drawer_width))
|
||
.into()
|
||
}
|
||
|
||
/// ListItem:三行高 × {选中 / 未选中 / 禁用}。
|
||
fn list_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||
let states = ["selected", "enabled", "disabled"];
|
||
|
||
let mut grid = column![].spacing(ui.space(Space::X4));
|
||
let header = states
|
||
.iter()
|
||
.map(|name| {
|
||
container(note(ui, name))
|
||
.width(Length::Fixed(ui.metrics().nav_drawer_width))
|
||
.into()
|
||
})
|
||
.collect::<Vec<Element<'a, DemoMessage>>>();
|
||
grid = grid.push(
|
||
row![
|
||
note(ui, "rows +16 each · 44 / 60 / 76"),
|
||
row(header).spacing(ui.space(Space::X2))
|
||
]
|
||
.spacing(ui.space(Space::X2)),
|
||
);
|
||
|
||
for (index, lines) in ["one line", "two lines", "three lines"].iter().enumerate() {
|
||
let mut cells = row![].spacing(ui.space(Space::X2));
|
||
for state in states {
|
||
// 三列的**内容完全相同**(同样的槽位),只有态不同——否则「选中 vs 未选中」没法逐像素比
|
||
let mut item = ListItem::new(lines, pressed()).leading(icon("instances"));
|
||
if index >= 1 {
|
||
item = item.supporting("3 versions · 1.21.4");
|
||
}
|
||
if index >= 2 {
|
||
item = item.overline("RECENT");
|
||
}
|
||
item = match state {
|
||
"selected" => item.selected().trailing(icon("settings")),
|
||
// 禁用是修饰器:这里只清消息,槽位全部保留
|
||
"disabled" => item.disabled(),
|
||
_ => item.state(Interaction::Enabled),
|
||
};
|
||
cells = cells.push(list_cell(ui, item));
|
||
}
|
||
grid = grid.push(cells);
|
||
}
|
||
|
||
column![
|
||
note(
|
||
ui,
|
||
"ListItem · 选中 = SecondaryContainer(偏离 D-M79-1:A1 的 ListItem 不换底色)· \
|
||
禁用 = OnSurface@38%(A1 ListTokens)"
|
||
),
|
||
grid,
|
||
]
|
||
.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()
|
||
}
|