Files
Unlucky-Launcher/crates/app-iced/src/components/navigation/list_item.rs
T
AskaEth 5174845cda fix(app-iced): 列表行圆角改从组件表取(M287)
`list_item.rs` 硬写 `ui.radius_dp(ShapeScale::None)`,旁边还写着「4.x:ListItem 的形状
是 `CornerNone`(直角),没有圆角档」——而令牌表 `component_map::table` 的 `ListItem`
是 `Small(8)`。产品侧根本没读表,注释在替错误站台。

改为 `row_border(ui)` → `ui.radius_dp(Component::ListItem.spec().shape)`,数值只留在
令牌表。变异实测(只改表的那一行 `Small→Large`):行圆角 8 → 16,240×60 选中行的顶行
缩进 6 → 13;改回后 11 张图逐字节复原(`DevDocs/artifacts/listradius/`,不入库)。

同 commit 改掉那条直角注释、模块文档补 4.9 行,并加两条单测:渲染路径的
`Border.radius` = 表值折算;源码里不许再出现硬编码的 `ShapeScale::*` 档。

外观影响的边界(写进取证报告):行只在 选中/悬停/按下 时有容器色,未选中静息与禁用
两态 `fill` 为 `None`,那两态逐字节不变是**正确**的,不是没生效。
2026-11-12 23:38:56 +08:00

345 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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` |
//! | 4.9 | 圆角 | 官方 `ListTokens` 是 `CornerNone`(直角);本项目**登记偏离**取 **8**(`Small`) | `Component::ListItem.spec().shape`(M287) |
//!
//! ## 禁用是**修饰器**,不是构造函数(照 `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, base, OnSurface, false)`
//! (`base` = 行脚下的底,默认 [`Base::CANVAS`]);用默认的 `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::component_map::Component;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use crate::icons;
use crate::theme::Ui;
use super::super::interaction::{self, Base, 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>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
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,
base: Base::CANVAS,
}
}
/// 加一行辅助文本 → **两行**项(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
}
/// **告诉列表项它踩在什么底上**(M234):禁用态的 38% 内容叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
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, base: Base, state: Interaction, selected: bool) -> ItemPalette {
if state.is_disabled() {
// A1:ListItemDisabled*Color = OnSurface × 0.38(无容器时叠在 surface 上)
let ink = interaction::disabled_content_over_role(ui, base, 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 {
// 状态层叠在**行脚下的底**(`base`)上,得到不透明结果(D27)。
// M234 复审 P2-3:原来写死 `Role::Surface`,卡片里的列表项抬起时会叠在画布色上。
fill: interaction::overlay_color(ui, state, Role::OnSurface, base.role()),
headline: ui.color(Role::OnSurface),
supporting: ui.color(Role::OnSurfaceVariant),
leading: ui.color(Role::OnSurfaceVariant),
trailing: ui.color(Role::OnSurface),
}
}
/// 一行的圆角档位:**只从组件表取**(`Component::ListItem.spec().shape`)。
///
/// 官方 `ListTokens` 是 `CornerNone`(直角行),本项目取 `Small`(8)——列表行在启动器里是
/// 卡片内的**独立可点块**,不是贴边通栏行,圆角与卡片同族更协调(偏离登记见
/// `crates/design/src/component_map/table.rs` 的 `ListItem` 条目)。
///
/// 数值归令牌表,**产品侧不许在这里再写一份**:改表值 ⇒ 行圆角跟着变(M287 的判据)。
fn shape_scale() -> ShapeScale {
Component::ListItem.spec().shape
}
/// 一行的描边:只画圆角,四边宽度与色都不画(`Border::default()` 的其余字段)。
///
/// 单独成函数是为了让「圆角真的来自组件表」可被单测直接量到
/// (`tests::row_corner_comes_from_the_component_table`)。
fn row_border(ui: &Ui) -> Border {
Border {
radius: ui.radius_dp(shape_scale()),
..Border::default()
}
}
/// 一行文本(显式行高:三行块的 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,
item.base,
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 base = item.base;
let border = row_border(ui);
let style = move |_theme: &iced::Theme, status: button::Status| {
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
let colors = palette(ui, base, state, selected);
button::Style {
background: colors.fill.map(Background::Color),
text_color: colors.headline,
border,
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;