Files
Unlucky-Launcher/crates/app-iced/src/components/navigation/list_item.rs
T
AskaEth 9bb3c33b8c fix(app-iced): 复审 r1 四条 P2 收口(M234)
- P2-1 判据恒绿:`gallery/probe/bases.rs` 的「反向检查」把字段档计数(`:172` 遮蔽后的
  `hits`)拿去比按钮档 stray ⇒ 恒绿。改成 `button_hits` / `field_hits` 两个计数,各比各的
  stray,失败信息的数值与单位对齐量测对象。变异自证:卡列里留一颗忘了 `.base(..)` 的按钮
  ⇒ 该断言单独红(2137 px vs 3178 px,退出码 101)。
- P2-2 FAB 的底错了:禁用态背景仍是满档 `PrimaryContainer`(`Disabled.layer() == None`),
  38% 内容却合在画布上。改成 `Base::of(PrimaryContainer)` + `on_container = false`
  (那一层 10% 在 FAB 身上不存在),两处调用收进一个 `content_color()`;新增值判据
  `disabled_fab_ink_composites_over_its_own_container`(修前 #8CA0AA / 修后 #548FBC 量级)。
- P2-3 漏改:`selection::paint::state_layer()`(hover/focus/pressed)与 `list_item` 的
  状态层叠底仍写死 `Role::Surface`,改成跟 `base`;同类两处(`assemble::transparent_state_color`
  的 Outlined/Text hover、drawer 项药丸)一并跟上。
- P2-4 文档失真:`feedback/{action,mod}.rs` 删掉「本地 α 合成 / 规则只认两档」的旧描述
  与失效链接;`list_item` 的旧签名;三处 `probe/base.rs` → `probe/bases.rs`;
  新增 `surfaces::elevated::tests::the_card_base_constant_is_the_role_the_card_paints`
  真断言(原来只在文档里声称那条测试守 `BASE`),并把这句描述改准。
- nit:`disabledbase.rs` 字符串里塌成一行的续行空格、probe 汇总行把列左缘横坐标说成
  「像素计数」的措辞。

门禁(CARGO_TARGET_DIR=C:/Temp/ull-target/wt-234):fmt / check --all-targets /
test -p unlucky-app-iced(896 + 6 通过,0 失败)/ clippy --workspace --all-targets -D warnings /
文件 ≤400 全绿;probe 浅色与深色都过。
2026-11-11 06:34:24 +08:00

324 lines
12 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` |
//!
//! ## 禁用是**修饰器**,不是构造函数(照 `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::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),
}
}
/// 一行文本(显式行高:三行块的 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 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,
// 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;