feat(app-iced): M77 输入家族 —— TextField / Search / Slider / Menu(含 gallery 登记)
D39 五家族并行的「输入类」这一条线。四个组件全部对消息类型泛型(`M: Clone`,不碰 `app::Message`),状态一律走 `components::interaction`,精确尺寸走 `components::layout`, 零尺寸字面量(取不到的集中登记 G16~G20 在 `inputs/geometry.rs`)。 - TextField(12b §9):Filled / Outlined;56 高(无标签 40)、4 圆角、内边距 16/16、 前置图标可视边 12(文本从 48 起)、指示线/描边 静息 1dp → 聚焦与错误 2dp、 错误态线/标签/辅助文本全 Error、禁用 12%/38%(Filled 容器官方 4%,令牌只有 12% → D10)。 标签常浮起(无上浮动画,D12);A1 无 Pressed token,press 按 hover(D11)。 - Search(15 §20):SearchBar(收起)+ SearchView(展开 docked)分开实现。bar 消费 `ComponentMetrics::SEARCH_BAR`,图标可视边 = pad 12 + icon_offset 4 = 16(只做加法, 不写 16/20),聚焦 1px Secondary;view 头部 56 + 1px **Outline** 分隔 + 结果区 240。 - Slider(12b §11):自绘 `advanced::Widget`——轨道 16 full、竖条 4×44(focus/pressed 收窄到 2)、 激活 Primary / 未激活 SecondaryContainer、命中 48、离散停靠点 4(未激活轨在点两侧留 6 空隙)。 位移区间取 handle/2..W−handle/2,所以 0%/100% 竖条不越界。 - Menu / DropdownMenuItem(12b §8):SurfaceContainer + L2 阴影(Elevation 令牌)、项 48、 水平 12、上下 8、选中 SecondaryContainer(指示器内缩 4、圆角 8)、分组分隔线复用 `components::divider`。圆角取令牌表 4(12b 说本项目 12 → D18)。 - gallery/inputs.rs:四组件状态矩阵登记(五态 × 两式 + 空/有值/错误、0%/50%/100%、菜单展开态), 按 `part` 拆成四段(整段比 760 高的窗口还高)。 单文件 ≤400 行:四个组件的单测与 Slider 的纯几何分别拆到 `<name>/tests.rs` 与 `slider/track.rs`。
This commit is contained in:
1 parent
78667953f5
commit
ddd234b3c8
12 files changed
+2675
-38
No files matched your search
@@ -0,0 +1,343 @@
|
||||
//! 菜单(`research/12b` §8):`Menu` 弹出层 + `DropdownMenuItem`。
|
||||
//!
|
||||
//! ## 参考值(`[源]` = A1,`MenuTokens` / `Menu.kt` 1.4.0)
|
||||
//!
|
||||
//! | # | 检查项 | 参考值 | 落点 |
|
||||
//! |---|---|---|---|
|
||||
//! | 8.1 | 容器圆角 | 官方 **4**(`ContainerShape = CornerExtraSmall`);`12b` 说「本项目 12」——**取令牌表的 4**(报告偏离 D18) | `Component::Menu.spec().shape` |
|
||||
//! | 8.2 | 容器色 / 高程 | `SurfaceContainer` + **L2** | `Role::SurfaceContainer` + `Elevation::Level2` |
|
||||
//! | 8.3 | 项高 | **48** | `MENU_ITEM_HEIGHT`(G19) |
|
||||
//! | 8.4 | 项内边距 | 水平 **12**、菜单上下留白 **8** | `Space::X3` / `Space::X2` |
|
||||
//! | 8.5 | 字级 / 图标 | 文本 `BodyMedium 14/20`;图标 **24** | `TypeRole` + `IconSize::Toolbar` |
|
||||
//! | 8.6 | 选中项 | 容器 `SecondaryContainer` + 文本 `OnSecondaryContainer`;指示器水平内缩 **4**、圆角 **8** | [`MenuItem::selected`] |
|
||||
//! | 8.7 | 宽度 | 不窄于触发元素、长文本不换行(`ellipsis` 本轮不做,登记 D19) | [`Menu::width`] |
|
||||
//!
|
||||
//! ## 弹出层阴影
|
||||
//!
|
||||
//! 高程取 `Component::Menu.spec().elevation`(= L2),阴影参数取 `Elevation::shadow()`
|
||||
//! (`ShadowSpec`:blur 6 / offset 2 / alpha 0.14,令牌层给的值),阴影色取 `Role::Shadow`。
|
||||
//! **容器色不取 `MaterialLayer::Overlay`**:那是 `SurfaceContainerHigh`,比 A1 的 `SurfaceContainer`
|
||||
//! 高一档;`MaterialLayer` 只用来确认「弹出层属于 Overlay 语义」,颜色以 A1 为准。
|
||||
//!
|
||||
//! ## 分组分隔线
|
||||
//!
|
||||
//! 复用 [`crate::app::components::divider::full`](一像素线 + `OutlineVariant`),**不自己画线**。
|
||||
//! 菜单水平内边距 12 由容器给,所以分隔线自然落在 12 内缩后的宽度上。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{button, column, container, text};
|
||||
use iced::{
|
||||
Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow, Vector,
|
||||
};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::Elevation;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::divider;
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::super::layout;
|
||||
use super::geometry::MenuGeometry;
|
||||
|
||||
/// 菜单里的一项。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MenuItem<'a, M> {
|
||||
label: &'a str,
|
||||
leading: Option<IconKind>,
|
||||
trailing: Option<IconKind>,
|
||||
on_press: Option<M>,
|
||||
selected: bool,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> MenuItem<'a, M> {
|
||||
/// 一个可点的项。
|
||||
pub fn new(label: &'a str, on_press: M) -> Self {
|
||||
Self {
|
||||
label,
|
||||
leading: None,
|
||||
trailing: None,
|
||||
on_press: Some(on_press),
|
||||
selected: false,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个禁用的项(无消息 = 点不动,与按钮家族同一口径)。
|
||||
pub fn disabled(label: &'a str) -> Self {
|
||||
Self {
|
||||
label,
|
||||
leading: None,
|
||||
trailing: None,
|
||||
on_press: None,
|
||||
selected: false,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 前置槽位(24)。
|
||||
pub fn leading(mut self, kind: IconKind) -> Self {
|
||||
self.leading = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 后置槽位(24;勾选/快捷键提示用)。
|
||||
pub fn trailing(mut self, kind: IconKind) -> Self {
|
||||
self.trailing = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 选中项(`SecondaryContainer` + `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
|
||||
}
|
||||
}
|
||||
|
||||
/// 菜单里的一行:普通项或分组分隔线。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Entry<'a, M> {
|
||||
/// 一个可点/禁用的项。
|
||||
Item(MenuItem<'a, M>),
|
||||
/// 分组分隔线(复用 `components::divider`)。
|
||||
Divider,
|
||||
}
|
||||
|
||||
/// 菜单弹出层。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Menu<'a, M> {
|
||||
entries: Vec<Entry<'a, M>>,
|
||||
width: Length,
|
||||
}
|
||||
|
||||
impl<'a, M> Default for Menu<'a, M> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, M> Menu<'a, M> {
|
||||
/// 空菜单(默认按容器宽度伸展,宽度由调用方定)。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
width: Length::Fill,
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加一项。
|
||||
pub fn item(mut self, item: MenuItem<'a, M>) -> Self {
|
||||
self.entries.push(Entry::Item(item));
|
||||
self
|
||||
}
|
||||
|
||||
/// 追加一条分组分隔线。
|
||||
pub fn divider(mut self) -> Self {
|
||||
self.entries.push(Entry::Divider);
|
||||
self
|
||||
}
|
||||
|
||||
/// 菜单宽度(8.7:不窄于触发元素由调用方保证)。
|
||||
pub fn width(mut self, width: Length) -> Self {
|
||||
self.width = width;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 一项的颜色(8.5 / 8.6 / 禁用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct ItemPalette {
|
||||
/// 项底色;`None` = 透明(不画状态层)。
|
||||
fill: Option<Color>,
|
||||
/// 文本色。
|
||||
ink: Color,
|
||||
/// 图标色(前置槽位;A1 是 `OnSecondaryContainer`)。
|
||||
icon: Color,
|
||||
/// 圆角档位(选中指示器 8)。
|
||||
shape: ShapeScale,
|
||||
}
|
||||
|
||||
/// 解析一项的颜色:选中优先,其余按五态叠状态层(`interaction::overlay_color`)。
|
||||
fn item_palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
|
||||
if state.is_disabled() {
|
||||
// 禁用:内容 38%(无容器时叠在表面色上;菜单项本身透明)
|
||||
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
|
||||
return ItemPalette {
|
||||
fill: None,
|
||||
ink,
|
||||
icon: ink,
|
||||
shape: ShapeScale::Small,
|
||||
};
|
||||
}
|
||||
|
||||
if selected {
|
||||
// 选中项也要跟态走:状态层叠在 SecondaryContainer 上(不透明结果,D27)
|
||||
let fill = interaction::overlay_color(
|
||||
ui,
|
||||
state,
|
||||
Role::OnSecondaryContainer,
|
||||
Role::SecondaryContainer,
|
||||
)
|
||||
.unwrap_or_else(|| ui.color(Role::SecondaryContainer));
|
||||
return ItemPalette {
|
||||
fill: Some(fill),
|
||||
ink: ui.color(Role::OnSecondaryContainer),
|
||||
icon: ui.color(Role::OnSecondaryContainer),
|
||||
shape: ShapeScale::Small,
|
||||
};
|
||||
}
|
||||
|
||||
ItemPalette {
|
||||
// 状态层叠在**菜单容器色**上(SurfaceContainer),得到不透明结果(D27)
|
||||
fill: interaction::overlay_color(ui, state, Role::OnSurface, Role::SurfaceContainer),
|
||||
ink: ui.color(Role::OnSurface),
|
||||
icon: ui.color(Role::OnSecondaryContainer),
|
||||
shape: ShapeScale::Small,
|
||||
}
|
||||
}
|
||||
|
||||
/// 菜单弹出层(`SurfaceContainer` + L2 阴影)。
|
||||
pub fn menu<'a, M: Clone + 'a>(ui: &'a Ui, menu: Menu<'a, M>) -> Element<'a, M> {
|
||||
let geometry = MenuGeometry::of(ui);
|
||||
let spec = Component::Menu.spec();
|
||||
let elevation = spec.elevation;
|
||||
debug_assert_eq!(
|
||||
elevation,
|
||||
Elevation::Level2,
|
||||
"MenuTokens.ContainerElevation"
|
||||
);
|
||||
let shadow_spec = elevation.shadow();
|
||||
let panel = ui.color(Role::SurfaceContainer);
|
||||
let shape = geometry.shape;
|
||||
|
||||
let mut rows = column![].width(Length::Fill);
|
||||
for entry in menu.entries {
|
||||
rows = rows.push(match entry {
|
||||
Entry::Item(item) => item_row(ui, item, geometry),
|
||||
// 分组分隔线:复用 `divider::full`(一像素线 + OutlineVariant),不自己画
|
||||
Entry::Divider => divider::full::<M>(ui),
|
||||
});
|
||||
}
|
||||
|
||||
container(layout::inset(0.0, geometry.pad_v, rows))
|
||||
.width(menu.width)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(panel)),
|
||||
border: Border {
|
||||
radius: ui.radius_dp(shape),
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow {
|
||||
color: Color {
|
||||
a: shadow_spec.alpha,
|
||||
..ui.color(Role::Shadow)
|
||||
},
|
||||
offset: Vector::new(0.0, shadow_spec.offset_y_px),
|
||||
blur_radius: shadow_spec.blur_px,
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一行菜单项(用 `button` 承载命中与四态,几何与颜色自绘)。
|
||||
fn item_row<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
item: MenuItem<'a, M>,
|
||||
geometry: MenuGeometry,
|
||||
) -> Element<'a, M> {
|
||||
let forced = item.state.or(if item.on_press.is_none() {
|
||||
Some(Interaction::Disabled)
|
||||
} else {
|
||||
None
|
||||
});
|
||||
// 选中项的背景也要跟态走:禁用时不许留满档 SecondaryContainer
|
||||
let colors = item_palette(ui, forced.unwrap_or(Interaction::Enabled), item.selected);
|
||||
let body = ui.style(TypeRole::BodyMedium);
|
||||
|
||||
let label = text(item.label)
|
||||
.size(body.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(body.line_height_px)))
|
||||
.font(ui.font_of(body))
|
||||
.color(colors.ink);
|
||||
|
||||
let mut row = iced::widget::Row::new()
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(geometry.gap);
|
||||
if let Some(kind) = item.leading {
|
||||
// Q9:图标必须经固定尺寸容器
|
||||
row = row.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, colors.icon),
|
||||
));
|
||||
}
|
||||
row = row.push(label);
|
||||
if let Some(kind) = item.trailing {
|
||||
row = row.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, colors.icon),
|
||||
));
|
||||
}
|
||||
|
||||
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 = item_palette(ui, state, selected);
|
||||
button::Style {
|
||||
background: colors.fill.map(Background::Color),
|
||||
text_color: colors.ink,
|
||||
border: Border {
|
||||
radius: ui.radius_dp(colors.shape),
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
};
|
||||
|
||||
let inset = ui.space(Space::X1);
|
||||
let button = button(row)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(geometry.item_height))
|
||||
.padding(Padding {
|
||||
top: 0.0,
|
||||
// 选中指示器水平内缩 4(8.6 / 参考图纸),内容仍落在 12 上:12 = 4 + 8
|
||||
right: geometry.pad_h - inset,
|
||||
bottom: 0.0,
|
||||
left: geometry.pad_h - inset,
|
||||
})
|
||||
.style(style);
|
||||
|
||||
let element: Element<'a, M> = match item.on_press {
|
||||
Some(message) => button.on_press(message).into(),
|
||||
None => button.into(),
|
||||
};
|
||||
|
||||
container(element)
|
||||
.width(Length::Fill)
|
||||
.padding(Padding {
|
||||
top: 0.0,
|
||||
right: inset,
|
||||
bottom: 0.0,
|
||||
left: inset,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user