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

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

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

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

No files matched your search

@@ -0,0 +1,303 @@
//! ListItem(`research/12b` §4):一行 / 两行 / 三行,leading / trailing 槽位,选中态,五态状态层。
//!
//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR 的 `ListTokens` / `ListItemColors`)
//!
//! | # | 检查项 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 4.1 | 一行高 | A1 **56** → 本项目 **44**(`LM.row_height`,登记偏离 −12) | [`ListItemGeometry::heights`] |
//! | 4.2 | 两 / 三行高 | A1 **72 / 88** → 本项目 **60 / 76**(每多一行 +16,与官方同 delta) | 同上 |
//! | 4.3 | 左右内边距 | **16 / 16** | `pad_start` / `pad_end` |
//! | 4.4 | leading | 图标 **24** `OnSurfaceVariant` | `IconSize::Toolbar` |
//! | 4.5 | 字级 | 主 `BodyLarge 16/24`、辅助 `BodyMedium 14/20`、overline `LabelSmall 11/16` | `TypeRole` |
//! | 4.7 | 状态 | hover/focus/pressed **文本与图标色不变**,只叠状态层(8/10/10%) | [`interaction`] |
//! | 4.8 | 选中 trailing | 图标 → **`Primary`** | `Role::Primary` |
//!
//! ## 禁用是**修饰器**,不是构造函数(照 `selection/chip.rs` 复审 P2-1 的结论)
//!
//! [`ListItem::disabled`] **只清消息**:图标、选中态、状态层全部保留。早期 chip 的
//! `Chip::disabled(label)` 构造函数把图标和选中态一起丢了,图上是「另一行」,不是「这一行禁用」。
//!
//! ## 禁用内容角色是 **`OnSurface`**,不是默认的 `OnSurfaceVariant`
//!
//! A1 实读(`javap -p -c androidx.compose.material3.tokens.ListTokens`):
//!
//! ```text
//! ListItemDisabledLabelTextColor = OnSurface (+ ListItemDisabledLabelTextOpacity = 0.38f)
//! ListItemDisabledLeadingIconColor = OnSurface (+ 0.38f)
//! ListItemDisabledTrailingIconColor = OnSurface (+ 0.38f)
//! ```
//!
//! 所以这里必须走 `interaction::disabled_content_over_role(ui, OnSurface, false)`;
//! 用默认的 `disabled_colors()`(写死 `OnSurfaceVariant`)会偏浅一档。
//!
//! ## 选中态:本项目口径(**偏离登记 D-M79-1**)
//!
//! A1 的 `ListItemColors` **没有** selected 容器色(实读字段:containerColor / headline / leadingIcon /
//! overline / supporting / trailingIcon + 三个 disabled),选中只把 trailing 图标换成 `Primary`。
//! mission 要求「选中态(SecondaryContainer)」,且本项目的菜单项(M77)与 drawer 项都用
//! `SecondaryContainer` 表示「当前项」——三处保持同一语言,故 ListItem 也填 `SecondaryContainer`
//! + `OnSecondaryContainer`。**这是实质偏离**:A1 的 ListItem 选中行不换底色。
use iced::widget::text::LineHeight;
use iced::widget::{button, container, space, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Interaction};
use super::super::layout;
use super::geometry::{
typography_headline, typography_overline, typography_supporting, ListItemGeometry,
};
/// 一个列表项(一行 / 两行 / 三行由槽位决定)。
#[derive(Debug, Clone)]
pub struct ListItem<'a, M> {
overline: Option<&'a str>,
headline: &'a str,
supporting: Option<&'a str>,
leading: Option<IconKind>,
trailing: Option<IconKind>,
on_press: Option<M>,
selected: bool,
state: Option<Interaction>,
}
impl<'a, M> ListItem<'a, M> {
/// 一行项(主文本 + 可点消息)。
pub fn new(headline: &'a str, on_press: M) -> Self {
Self {
overline: None,
headline,
supporting: None,
leading: None,
trailing: None,
on_press: Some(on_press),
selected: false,
state: None,
}
}
/// 加一行辅助文本 → **两行**项(4.5:辅助 `BodyMedium`)。
pub fn supporting(mut self, supporting: &'a str) -> Self {
self.supporting = Some(supporting);
self
}
/// 加上方 overline → **三行**项(4.5:overline `LabelSmall`,`OnSurfaceVariant`)。
pub fn overline(mut self, overline: &'a str) -> Self {
self.overline = Some(overline);
self
}
/// 前置槽位(图标 24,`OnSurfaceVariant`)。
pub fn leading(mut self, kind: IconKind) -> Self {
self.leading = Some(kind);
self
}
/// 后置槽位(未选中 `OnSurface`,选中 `Primary`,见 4.8)。
pub fn trailing(mut self, kind: IconKind) -> Self {
self.trailing = Some(kind);
self
}
/// 选中态(`SecondaryContainer` + `OnSecondaryContainer`;偏离 D-M79-1 见模块文档)。
pub fn selected(mut self) -> Self {
self.selected = true;
self
}
/// 强制视觉态(gallery 状态矩阵取证;不动状态机)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// 转禁用:**只清消息**,槽位与选中态一律保留。
pub fn disabled(mut self) -> Self {
self.on_press = None;
self
}
/// 行数(1 + 有 overline + 有 supporting)。
pub fn lines(&self) -> usize {
usize::from(self.overline.is_some()) + 1 + usize::from(self.supporting.is_some())
}
}
/// 一项的颜色(按态 + 选中求值;禁用按 A1 的 `OnSurface`@38%)。
#[derive(Debug, Clone, Copy, PartialEq)]
struct ItemPalette {
fill: Option<Color>,
headline: Color,
supporting: Color,
leading: Color,
trailing: Color,
}
fn palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
if state.is_disabled() {
// A1:ListItemDisabled*Color = OnSurface × 0.38(无容器时叠在 surface 上)
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
return ItemPalette {
fill: None,
headline: ink,
supporting: ink,
leading: ink,
trailing: ink,
};
}
if selected {
let fill = interaction::overlay_color(
ui,
state,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
)
.unwrap_or_else(|| ui.color(Role::SecondaryContainer));
return ItemPalette {
fill: Some(fill),
headline: ui.color(Role::OnSecondaryContainer),
supporting: ui.color(Role::OnSecondaryContainer),
leading: ui.color(Role::OnSecondaryContainer),
// 4.8:选中时 trailing 图标是 `Primary`(A1 ListItemSelectedTrailingIconColor)
trailing: ui.color(Role::Primary),
};
}
ItemPalette {
// 状态层叠在**行自己的底**(Surface)上,得到不透明结果(D27)
fill: interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface),
headline: ui.color(Role::OnSurface),
supporting: ui.color(Role::OnSurfaceVariant),
leading: ui.color(Role::OnSurfaceVariant),
trailing: ui.color(Role::OnSurface),
}
}
/// 一行文本(显式行高:三行块的 16+24+20 必须能精确等于 60,靠的就是它)。
fn line<'a, M: 'a>(
ui: &'a Ui,
value: &'a str,
role: unlucky_design::typography::TypeRole,
color: Color,
) -> Element<'a, M> {
let style = ui.style(role);
text(value)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.into()
}
/// 列表项(`research/12b` §4)。
pub fn list_item<'a, M: Clone + 'a>(ui: &'a Ui, item: ListItem<'a, M>) -> Element<'a, M> {
let geometry = ListItemGeometry::of(ui);
let lines = item.lines();
let height = geometry.height(lines);
// 禁用 = 「没有消息」;`state` 是取证强制值,优先级更高(矩阵里画 hover 的禁用项也成立)
let forced = item.state.or(if item.on_press.is_none() {
Some(Interaction::Disabled)
} else {
None
});
let colors = palette(ui, forced.unwrap_or(Interaction::Enabled), item.selected);
let mut body = iced::widget::Column::new().spacing(0);
if let Some(overline) = item.overline {
body = body.push(line::<M>(
ui,
overline,
typography_overline(),
colors.supporting,
));
}
body = body.push(line::<M>(
ui,
item.headline,
typography_headline(),
colors.headline,
));
if let Some(supporting) = item.supporting {
body = body.push(line::<M>(
ui,
supporting,
typography_supporting(),
colors.supporting,
));
}
let mut content = iced::widget::Row::new()
.align_y(Alignment::Center)
.spacing(geometry.gap);
if let Some(kind) = item.leading {
// Q9:图标必须经固定尺寸容器(否则会被按钮的 Fill 约束撑满)
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.leading),
));
}
content = content.push(body);
if let Some(kind) = item.trailing {
// trailing 必须在行尾:label 与 trailing 之间插一条水平 Fill(`inputs/menu.rs` 的现行写法)
content = content.push(space::horizontal());
content = content.push(layout::fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(kind, colors.trailing),
));
}
let pad_y = geometry.pad_y(ui, lines);
// 行必须撑满 item 宽度(Fill 空间要有宽度可撑),垂直靠 align_y 居中(`button` 默认贴左上)
let body = container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center);
let selected = item.selected;
let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
let colors = palette(ui, state, selected);
button::Style {
background: colors.fill.map(Background::Color),
text_color: colors.headline,
// 4.x:ListItem 的形状是 `CornerNone`(直角),没有圆角档
border: Border {
radius: ui.radius_dp(ShapeScale::None),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
};
let button = button(body)
.width(Length::Fill)
.height(Length::Fixed(height))
.padding(Padding {
top: pad_y,
right: geometry.pad_end,
bottom: pad_y,
left: geometry.pad_start,
})
.style(style);
let element: Element<'a, M> = match item.on_press {
Some(message) => button.on_press(message).into(),
None => button.into(),
};
element
}
#[cfg(test)]
mod tests;