diff --git a/crates/app-iced/src/components/gallery/inputs.rs b/crates/app-iced/src/components/gallery/inputs.rs index 0245f67..04f8e20 100644 --- a/crates/app-iced/src/components/gallery/inputs.rs +++ b/crates/app-iced/src/components/gallery/inputs.rs @@ -1,22 +1,341 @@ -//! gallery 里的输入类小节(**桩**):后续 mission 只改本文件。 +//! gallery 里的输入类小节(D30 的状态矩阵登记):TextField / Search / Slider / Menu。 +//! +//! ## 为什么分成四个 `part` +//! +//! 一整段(四组件 × 五态 × 两式)比 760 高的窗口还高,`--screen=gallery` 的截图只能看到顶部。 +//! 所以本文件把证据拆成四个自足的 `part`([`text_field_part`] / [`search_part`] / +//! [`slider_part`] / [`menu_part`]),[`section`] 按 D30 的顺序把它们竖排—— +//! 出图时用副本工程把 `section()` 换成单个 `part`(只调段序、不改组件代码,报告里有 diff 与说明)。 +//! +//! ## 矩阵形状 +//! +//! ```text +//! TextField(两行 × 五态) 列 = enabled / hovered / pressed / focused / disabled +//! filled | [字段] | ... +//! outlined | [字段] | ... +//! 另加三格:空 / 有值 / 错误(9.7 的判据要单独一眼) +//! +//! Search (五行 + 一个展开面板) +//! Slider (五态一行 + 0% / 50% / 100% 三行 + 离散 4 段一行) +//! Menu (五态各一项的一只菜单 + 一只「展开态」菜单:选中 / 分隔线 / 禁用) +//! ``` +//! +//! ## 每个格子都强制状态 +//! +//! 与按钮家族同一个理由:截图时没法让 20 个控件同时 hover。`state(..)` 只是取证开关, +//! 不动任何状态机——图上的五态与运行期五态同源。 -use iced::Element; +use iced::widget::{column, container, row, text}; +use iced::{Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; -use super::super::inputs; use crate::theme::Ui; -use super::{heading, DemoMessage}; +use super::super::inputs::{ + self, Field, FieldVariant, Menu, MenuItem, SearchBar, SearchView, Slider, +}; +use super::super::interaction::Interaction; +use super::{cell_width, heading, row_label, state_cells, DemoMessage}; -/// 输入类家族的小节。 +/// 演示消息(组件只认泛型,gallery 的映射在这里落地)。 +fn pressed(id: &'static str) -> DemoMessage { + DemoMessage::Pressed(id) +} + +/// 输入类家族整段(四个 part 竖排;见模块文档的「为什么分成四个 part」)。 pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { - iced::widget::column![ + column![ heading( ui, "inputs · text field / search / slider / menu", - "12b §8/§9/§11 + 15 §20 —— pending(后续 mission 填充本文件)" + "12b §9/§11/§8 + 15 §20:TextField 高 56(无标签 40)· Search 条 56 药丸 + 图标可视边 16 \ + (= pad 12 + offset 4)· Slider 轨 16 + 竖条 4×44 · Menu 项 48 + L2 阴影" ), - inputs::demo::(ui), + text_field_part(ui), + search_part(ui), + slider_part(ui), + menu_part(ui), ] - .spacing(ui.space(unlucky_design::spacing::Space::X2)) + .spacing(ui.space(Space::X8)) + .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() +} + +/// 固定宽的矩阵格。 +fn cell<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, DemoMessage> { + container(content) + .width(Length::Fixed(cell_width(ui))) + .into() +} + +/// 五态列头。 +fn state_header<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { + let style = ui.style(TypeRole::LabelSmall); + let mut head = row![row_label(ui, "")].spacing(ui.space(Space::X2)); + for (name, width) in state_cells(ui) { + head = head.push( + container( + text(name) + .size(style.size_px) + .font(ui.font_of(style)) + .color(ui.color(Role::OnSurfaceVariant)), + ) + .width(Length::Fixed(width)), + ); + } + head.into() +} + +/// 一行矩阵(左侧标签 + 五格)。 +fn state_row<'a>( + ui: &'a Ui, + label: &'a str, + cells: Vec>, +) -> Element<'a, DemoMessage> { + row![ + row_label(ui, label), + row(cells).spacing(ui.space(Space::X2)) + ] + .spacing(ui.space(Space::X2)) + .into() +} + +/// TextField:两式 × 五态 + 空 / 有值 / 错误 三格。 +fn text_field_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { + let mut matrix = column![state_header(ui)].spacing(ui.space(Space::X2)); + + for variant in FieldVariant::ALL { + let cells = Interaction::ALL + .iter() + .map(|state| { + cell( + ui, + inputs::text_field( + ui, + Field::new("实例一") + .variant(variant) + .label("名称") + .state(*state), + |_| pressed("field"), + ), + ) + }) + .collect::>(); + matrix = matrix.push(state_row(ui, variant.name(), cells)); + } + + // 三格用 **320** 宽(= 2 个矩阵格):9.2 的最小宽 280 只在这里可验(矩阵格 160 更窄) + let wide = Length::Fixed(cell_width(ui) * 2.0); + let extras = row![ + inputs::text_field( + ui, + Field::new("") + .label("名称") + .placeholder("请输入名称") + .width(wide), + |_| pressed("field"), + ), + inputs::text_field( + ui, + Field::new("1.21.4") + .label("名称") + .leading(IconKind::Folder) + .width(wide), + |_| pressed("field"), + ), + inputs::text_field( + ui, + Field::new("") + .variant(FieldVariant::Outlined) + .label("名称") + .placeholder("必填") + .trailing(IconKind::Error) + .supporting("该名称已被占用") + .error() + .width(wide), + |_| pressed("field"), + ), + ] + .spacing(ui.space(Space::X2)); + + column![ + note( + ui, + "列 = 五态 · 行 = 两式。静息指示线/描边 1px、聚焦与错误 2px(A1 FocusOutlineWidth);\ + 错误态线/标签/辅助文本全 Error" + ), + matrix, + note( + ui, + "另加:空(占位 OnSurfaceVariant)/ 有值(带前置图标 24)/ 错误(2px + 辅助文本)" + ), + extras, + ] + .spacing(ui.space(Space::X2)) + .into() +} + +/// Search:五态 bar 竖排 + 一个展开的 docked 面板。 +fn search_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { + let bar_width = ui.space(Space::X8) * 12.0; + let mut bars = column![].spacing(ui.space(Space::X2)); + for state in Interaction::ALL { + let bar = inputs::search_bar( + ui, + SearchBar::new("") + .placeholder("搜索实例 / 版本 / mod") + .state(state) + .width(Length::Fixed(bar_width)), + |_| pressed("search"), + ); + bars = bars.push( + row![row_label(ui, state.name()), bar] + .spacing(ui.space(Space::X2)) + .align_y(iced::Alignment::Center), + ); + } + + let results: &[&str] = &[ + "实例:原版 1.21.4", + "版本:Fabric 0.16.9", + "mod:Sodium 0.6.5", + ]; + let view = inputs::search_view( + ui, + SearchView::new("1.21", results, "没有匹配的实例").placeholder("搜索实例 / 版本 / mod"), + |_| pressed("search"), + ); + + column![ + note( + ui, + "bar:高 56 · 圆角 full(28) · SurfaceContainerHigh · 图标可视边 16 = pad 12 + offset 4(M69 不变量)· \ + 聚焦 1px Secondary;view:docked,头部 56 + 1px Outline 分隔 + 结果区最小高 240" + ), + row![bars, view].spacing(ui.space(Space::X6)), + ] + .spacing(ui.space(Space::X2)) + .into() +} + +/// Slider:五态一行 + 0%/50%/100% 三行 + 离散 4 段一行。 +fn slider_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { + let cells = Interaction::ALL + .iter() + .map(|state| { + let enabled = !state.is_disabled(); + let mut spec = Slider::new(ui, 0.5).state(*state); + if enabled { + spec = spec.on_change(|_| pressed("slider")); + } + cell(ui, inputs::slider(spec)) + }) + .collect::>(); + + let track_width = ui.space(Space::X8) * 14.0; + let mut values = column![].spacing(ui.space(Space::X2)); + for (label, fraction) in [("0%", 0.0), ("50%", 0.5), ("100%", 1.0)] { + let spec = Slider::new(ui, fraction) + .on_change(|_| pressed("slider")) + .state(Interaction::Enabled); + values = values.push( + row![ + row_label(ui, label), + container(inputs::slider(spec)).width(Length::Fixed(track_width)), + ] + .spacing(ui.space(Space::X2)) + .align_y(iced::Alignment::Center), + ); + } + + // 离散:4 段 = 3 个内部停靠点(25 / 50 / 75%),当前 25% ⇒ 能看到 50% 与 75% 两个点 + let discrete = row![ + row_label(ui, "离散 4"), + container(inputs::slider( + Slider::new(ui, 0.25) + .steps(4) + .on_change(|_| pressed("slider")) + .state(Interaction::Enabled) + )) + .width(Length::Fixed(track_width)), + ] + .spacing(ui.space(Space::X2)) + .align_y(iced::Alignment::Center); + + column![ + note( + ui, + "轨 16 full(Primary / SecondaryContainer)· 竖条 4×44 full(overflow 上下各 14)· \ + focus/pressed 收窄到 2 · 命中 48 · 离散停靠点 4" + ), + state_row(ui, "五态", cells), + note(ui, "0% / 50% / 100%:竖条在两端贴边但不越界(左缘 = 轨道左缘,右缘 = 轨道右缘)"), + values, + note( + ui, + "离散:steps(4) ⇒ 3 个内部停靠点(25/50/75%),未激活轨在停靠点两侧留 6 空隙,\n 激活段只画到竖条中心", + ), + discrete, + ] + .spacing(ui.space(Space::X2)) + .into() +} + +/// Menu:一只五态菜单 + 一只展开态菜单(选中 / 分组线 / 禁用)。 +fn menu_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { + let width = ui.space(Space::X8) * 8.0; + + let mut states = Menu::new().width(Length::Fixed(width)); + for state in Interaction::ALL { + let mut item = MenuItem::new("重命名", pressed("menu")).state(state); + if state.is_disabled() { + item = MenuItem::disabled("重命名").state(state); + } + states = states.item(item.leading(IconKind::Folder)); + } + + let open = Menu::new() + .width(Length::Fixed(width)) + .item(MenuItem::new("重命名", pressed("menu")).leading(IconKind::Folder)) + .item( + MenuItem::new("复制实例", pressed("menu")) + .leading(IconKind::Download) + .trailing(IconKind::Check) + .selected(), + ) + .divider() + .item(MenuItem::disabled("删除").leading(IconKind::Trash)); + + column![ + note( + ui, + "容器 SurfaceContainer + L2 阴影(blur 6 / offset 2 / Shadow 角色)· 圆角 4(令牌表)· \ + 项高 48 · 水平 12 · 上下留白 8 · 选中项 SecondaryContainer 指示器内缩 4、圆角 8" + ), + row![ + column![note(ui, "五态(每项强制一态)"), inputs::menu(ui, states)] + .spacing(ui.space(Space::X2)), + column![ + note(ui, "展开:含选中项 / 分组分隔线(复用 divider)/ 不可点项"), + inputs::menu(ui, open) + ] + .spacing(ui.space(Space::X2)), + ] + .spacing(ui.space(Space::X6)), + ] + .spacing(ui.space(Space::X2)) .into() } diff --git a/crates/app-iced/src/components/inputs/field.rs b/crates/app-iced/src/components/inputs/field.rs new file mode 100644 index 0000000..ee9c34d --- /dev/null +++ b/crates/app-iced/src/components/inputs/field.rs @@ -0,0 +1,393 @@ +//! 文本框(`research/12b` §9):Filled / Outlined 两式,**受控组件**。 +//! +//! ## 参考值(`[源]` = A1,本机 `material3-android:1.4.0` 的 `*TextFieldTokens` + `TextFieldDefaults`) +//! +//! | # | 检查项 | 参考值 | 落点 | +//! |---|---|---|---| +//! | 9.1 | 高度 | min **56**(带标签);无标签单行本项目 **40** | `FIELD_HEIGHT`(G16)/ `LM.control_height` | +//! | 9.2 | 最小宽 | **280** → **G21 未施加**(iced 的 `Length` 无 min-width,同按钮 G15) | 文档 | +//! | 9.3 | 形状 | Outlined **4**;Filled 顶圆角 4、底直角 | `Component::TextField.spec().shape` | +//! | 9.4 | 描边 / 指示线 | Outlined 静息 1dp `Outline` → hover 1dp `OnSurface` → 聚焦 **2dp `Primary`**;Filled 底指示线静息 `OnSurfaceVariant` | [`Filled`] / [`Palette`] | +//! | 9.5 | 字级 | 输入/标签 `BodyLarge 16/24`;辅助/错误 `BodySmall 12/16` | `TypeRole` | +//! | 9.6 | 图标 | 前后 **24**、`OnSurfaceVariant`;图标可视边 **12**、文本从 **48** 起 | `FieldGeometry` | +//! | 9.7 | 错误态 | 线/标签/辅助文本全 **`Error`**、线宽 **2dp** | [`Field::error`] | +//! | 9.8 | 禁用态 | Filled 容器 `OnSurface`@**4%**(→ 令牌只有 12%,**登记偏离 D10**);Outlined 描边 `OnSurface`@**12%**;内容 @**38%** | `interaction::*` | +//! +//! ## 三处口径说明 +//! +//! 1. **状态差异落在指示线/描边/标签上,容器底色不叠状态层**——这是 A1 文本字段的口径 +//! (`HoverActiveIndicatorColor` / `FocusActiveIndicatorColor` 等 token),与按钮家族 +//! 「叠一个 8%/10% 的状态层」不同。press 在 A1 里**没有**独立 token,本实现按 hover 处理(登记 D11)。 +//! 2. 标签是「**常浮起**」的两段式:有标签就固定占上面那 16px(`BodySmall`),不做上浮动画 +//! (M3 的动画由 motion mission 统一接,登记 D12)。 +//! 3. 缺省宽度 `Length::Fill`:文本框在页面里按列宽伸展,最小宽 280 无法在 iced 表达(G21)。 + +use iced::widget::text::LineHeight; +use iced::widget::{column, container, row, text, text_input}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::icons; +use crate::theme::Ui; + +use super::super::interaction::{self, Interaction}; +use super::super::layout; +use super::geometry::FieldGeometry; + +/// 两式容器(12b 9.3 / 9.4 的差别:Filled 有容器色 + 底部指示线;Outlined 只有描边)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FieldVariant { + /// 实心:`SurfaceContainerHighest` + 底部指示线,顶圆角 4 底直角。 + Filled, + /// 描边:透明容器 + 1dp `Outline`,四角 4。 + Outlined, +} + +impl FieldVariant { + /// 两式全序(gallery 的行序)。 + pub const ALL: [FieldVariant; 2] = [FieldVariant::Filled, FieldVariant::Outlined]; + + /// 稳定名字(图例 / 测试信息)。 + pub const fn name(self) -> &'static str { + match self { + FieldVariant::Filled => "filled", + FieldVariant::Outlined => "outlined", + } + } +} + +/// 一个文本框的装配描述(不持有 `String`:受控组件只借值,契约 §2)。 +#[derive(Debug, Clone)] +pub struct Field<'a> { + variant: FieldVariant, + value: &'a str, + label: Option<&'a str>, + placeholder: &'a str, + supporting: Option<&'a str>, + leading: Option, + trailing: Option, + error: bool, + state: Option, + width: Length, +} + +impl<'a> Field<'a> { + /// 一个 Filled 文本框。 + pub fn new(value: &'a str) -> Self { + Self { + variant: FieldVariant::Filled, + value, + label: None, + placeholder: "", + supporting: None, + leading: None, + trailing: None, + error: false, + state: None, + width: Length::Fill, + } + } + + /// 选式(Filled / Outlined)。 + pub fn variant(mut self, variant: FieldVariant) -> Self { + self.variant = variant; + self + } + + /// 浮动标签(`BodySmall`,常浮起,见模块文档第 2 条)。 + pub fn label(mut self, label: &'a str) -> Self { + self.label = Some(label); + self + } + + /// 占位文案(只在值为空时可见,`OnSurfaceVariant`)。 + pub fn placeholder(mut self, placeholder: &'a str) -> Self { + self.placeholder = placeholder; + self + } + + /// 辅助/错误说明(字段下方 `BodySmall`)。 + pub fn supporting(mut self, supporting: &'a str) -> Self { + self.supporting = Some(supporting); + self + } + + /// 前置图标(24,图标可视边 12)。 + pub fn leading(mut self, kind: IconKind) -> Self { + self.leading = Some(kind); + self + } + + /// 后置图标(24,右侧内边距 16)。 + pub fn trailing(mut self, kind: IconKind) -> Self { + self.trailing = Some(kind); + self + } + + /// 错误态:线 / 标签 / 辅助文本转 `Error`,线宽 2dp(9.7)。 + pub fn error(mut self) -> Self { + self.error = true; + self + } + + /// 强制视觉态(gallery 状态矩阵取证;运行期不要调)。 + pub fn state(mut self, state: Interaction) -> Self { + self.state = Some(state); + self + } + + /// 定宽(默认 `Fill`;gallery 的矩阵格用它钉宽度)。 + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } +} + +/// 一个字段在某一态下的全套颜色(9.4 / 9.7 / 9.8 的落点)。 +#[derive(Debug, Clone, Copy, PartialEq)] +struct Palette { + /// 容器底色;`None` = 透明(Outlined)。 + fill: Option, + /// 描边/指示线色。 + line: Color, + /// 描边/指示线宽(1 或 2)。 + stroke: f32, + /// 标签(`BodySmall`)。 + label: Color, + /// 输入文本。 + input: Color, + /// 前后图标。 + icon: Color, + /// 辅助文本。 + supporting: Color, + /// 占位文本。 + placeholder: Color, +} + +/// 解析某一态的颜色(**唯一**做角色映射的地方)。 +fn palette(ui: &Ui, variant: FieldVariant, state: Interaction, error: bool) -> Palette { + let geometry = FieldGeometry::of(ui, true, None); + + if state.is_disabled() { + // 9.8:内容一律 OnSurface@38%;Filled 容器叠 12%(官方 4%,令牌无 4% 档 → D10); + // Outlined 描边正好是官方 12%。 + let container = interaction::disabled_colors(ui, Role::Surface).0; + let ink = interaction::disabled_content_over_role( + ui, + Role::OnSurface, + matches!(variant, FieldVariant::Filled), + ); + return Palette { + fill: match variant { + FieldVariant::Filled => Some(container), + FieldVariant::Outlined => None, + }, + line: match variant { + FieldVariant::Filled => ink, + FieldVariant::Outlined => container, + }, + stroke: geometry.stroke, + label: ink, + input: ink, + icon: ink, + supporting: ink, + placeholder: ink, + }; + } + + let focused = matches!(state, Interaction::Focused); + let hovered = matches!(state, Interaction::Hovered | Interaction::Pressed); + + let (line, stroke) = if error { + (ui.color(Role::Error), geometry.focus_stroke) + } else if focused { + (ui.color(Role::Primary), geometry.focus_stroke) + } else if hovered { + (ui.color(Role::OnSurface), geometry.stroke) + } else { + ( + match variant { + FieldVariant::Filled => ui.color(Role::OnSurfaceVariant), + FieldVariant::Outlined => ui.color(Role::Outline), + }, + geometry.stroke, + ) + }; + + Palette { + fill: match variant { + FieldVariant::Filled => Some(ui.color(Role::SurfaceContainerHighest)), + FieldVariant::Outlined => None, + }, + line, + stroke, + label: if error { + ui.color(Role::Error) + } else if focused { + ui.color(Role::Primary) + } else { + ui.color(Role::OnSurfaceVariant) + }, + input: ui.color(Role::OnSurface), + icon: if error { + ui.color(Role::Error) + } else { + ui.color(Role::OnSurfaceVariant) + }, + supporting: if error { + ui.color(Role::Error) + } else { + ui.color(Role::OnSurfaceVariant) + }, + placeholder: ui.color(Role::OnSurfaceVariant), + } +} + +/// 一个文本框(Filled / Outlined 由 [`Field::variant`] 选)。 +/// +/// 受控:值由外部持有,输入经 `on_input` 回传;`state` 强制成 `Disabled` 时输入被摘掉 +/// (`on_input_maybe(None)`),所以禁用字段真的点不动,不只是变灰。 +pub fn text_field<'a, M: Clone + 'a>( + ui: &'a Ui, + field: Field<'a>, + on_input: impl Fn(String) -> M + 'a, +) -> Element<'a, M> { + let geometry = FieldGeometry::of(ui, field.label.is_some(), field.leading); + let state = field.state.unwrap_or(Interaction::Enabled); + let colors = palette(ui, field.variant, state, field.error); + + let input_style = ui.style(TypeRole::BodyLarge); + let label_style = ui.style(TypeRole::BodySmall); + + let text_input = text_input(field.placeholder, field.value) + .on_input_maybe(if state.is_disabled() { + None + } else { + Some(on_input) + }) + .size(input_style.size_px) + .line_height(LineHeight::Absolute(Pixels(input_style.line_height_px))) + .font(ui.font_of(input_style)) + .padding(Padding::default()) + .width(Length::Fill) + .style(move |_theme, _status| text_input::Style { + background: Background::Color(Color::TRANSPARENT), + border: Border::default(), + icon: colors.icon, + placeholder: colors.placeholder, + value: colors.input, + selection: colors.line, + }); + + let mut content = row![].align_y(Alignment::Center).spacing(geometry.icon_gap); + if let Some(kind) = field.leading { + // Q9:图标必须经固定尺寸容器,否则会被 Fill 约束撑满(M68 的 46px 字形) + content = content.push(layout::fixed( + geometry.icon, + geometry.icon, + icons::view::(kind, colors.icon), + )); + } + content = content.push(text_input); + if let Some(kind) = field.trailing { + content = content.push(layout::fixed( + geometry.icon, + geometry.icon, + icons::view::(kind, colors.icon), + )); + } + + let label = field.label.map(|label| { + text(label) + .size(label_style.size_px) + .line_height(LineHeight::Absolute(Pixels(label_style.line_height_px))) + .font(ui.font_of(label_style)) + .color(colors.label) + }); + + // 内容块:带标签 = [标签 16 + 输入 24] 共 40,居中在内容区里;无标签 = 单行 24 居中。 + // **垂直居中交给容器**(Q8 的教训:不要指望子元素自己贴边)。 + let inner: Element<'a, M> = match label { + Some(label) => column![label, content].width(Length::Fill).into(), + None => content.into(), + }; + let content_area = container(inner) + .width(Length::Fill) + .height(Length::Fill) + .align_y(Alignment::Center) + .padding(Padding { + top: 0.0, + right: geometry.padding.right, + bottom: 0.0, + left: geometry.padding.left, + }); + + // Filled:容器色 + **底部指示线**(静息 1dp / 聚焦与错误 2dp);Outlined:透明 + 描边。 + // 指示线占容器底边那 1~2px(内容区因此少 1~2px,官方也是画在 padding-box 底边上的)。 + let body: Element<'a, M> = match field.variant { + FieldVariant::Filled => { + let indicator = container(iced::widget::space::horizontal()) + .width(Length::Fill) + .height(Length::Fixed(colors.stroke)) + .style(move |_theme| container::Style { + background: Some(Background::Color(colors.line)), + ..container::Style::default() + }); + column![content_area, indicator].into() + } + FieldVariant::Outlined => content_area.into(), + }; + + let border = Border { + color: if matches!(field.variant, FieldVariant::Outlined) { + colors.line + } else { + Color::TRANSPARENT + }, + width: if matches!(field.variant, FieldVariant::Outlined) { + colors.stroke + } else { + 0.0 + }, + // Filled 是「顶圆角 4、底直角」= CornerExtraSmallTop;ShapeScale 没有「部分角」, + // 这里按官方语义手工指定上下角(登记 D13)。 + radius: match field.variant { + FieldVariant::Filled => iced::border::Radius { + top_left: ui.radius_dp(geometry.shape).top_left, + top_right: ui.radius_dp(geometry.shape).top_right, + bottom_left: 0.0, + bottom_right: 0.0, + }, + FieldVariant::Outlined => ui.radius_dp(geometry.shape), + }, + }; + + let field_el: Element<'a, M> = container(body) + .width(field.width) + .height(Length::Fixed(geometry.height)) + .style(move |_theme| container::Style { + background: colors.fill.map(Background::Color), + border, + ..container::Style::default() + }) + .into(); + + match field.supporting { + Some(supporting) => { + let note = text(supporting) + .size(label_style.size_px) + .line_height(LineHeight::Absolute(Pixels(label_style.line_height_px))) + .font(ui.font_of(label_style)) + .color(colors.supporting); + column![field_el, layout::inset(0.0, ui.space(Space::X1), note)].into() + } + None => field_el, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/inputs/field/tests.rs b/crates/app-iced/src/components/inputs/field/tests.rs new file mode 100644 index 0000000..f24e9ff --- /dev/null +++ b/crates/app-iced/src/components/inputs/field/tests.rs @@ -0,0 +1,115 @@ +//! field 的单测(从 `field.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; + +use crate::theme::Families; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) +} + +fn build(field: Field<'_>, flavor: SchemeFlavor) -> Element<'_, u8> { + let ui = Box::leak(Box::new(ui(flavor))); + text_field(ui, field, |_| 0_u8) +} + +#[test] +fn every_variant_and_state_builds() { + for variant in FieldVariant::ALL { + for state in Interaction::ALL { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let field = Field::new("实例一") + .variant(variant) + .label("名称") + .placeholder("请输入") + .supporting("辅助说明") + .leading(IconKind::Folder) + .trailing(IconKind::Close) + .state(state); + let _: Element<'_, u8> = build(field, flavor); + } + } + } +} + +/// 9.4:聚焦 2dp `Primary`、hover 1dp `OnSurface`、静息 Outlined 1dp `Outline`(Filled 用 OnSurfaceVariant)。 +#[test] +fn outline_colors_and_widths_follow_the_state() { + let ui = ui(SchemeFlavor::Light); + let rest = palette(&ui, FieldVariant::Outlined, Interaction::Enabled, false); + assert_eq!(rest.line, ui.color(Role::Outline)); + assert_eq!(rest.stroke, 1.0); + assert_eq!(rest.fill, None, "Outlined 无容器色"); + + let hover = palette(&ui, FieldVariant::Outlined, Interaction::Hovered, false); + assert_eq!(hover.line, ui.color(Role::OnSurface)); + assert_eq!(hover.stroke, 1.0); + + let focus = palette(&ui, FieldVariant::Outlined, Interaction::Focused, false); + assert_eq!(focus.line, ui.color(Role::Primary)); + assert_eq!(focus.stroke, 2.0, "FocusOutlineWidth = 2dp"); + assert_eq!(focus.label, ui.color(Role::Primary), "FocusLabelColor"); + + // A1 没有 Pressed token:按 hover 处理(D11),不是漏做 + assert_eq!( + palette(&ui, FieldVariant::Outlined, Interaction::Pressed, false), + hover + ); +} + +/// 9.7:错误态线/标签/辅助全 `Error`,线宽 2dp;错误优先于聚焦。 +#[test] +fn error_beats_focus_and_paints_everything_error() { + let ui = ui(SchemeFlavor::Light); + let error = palette(&ui, FieldVariant::Outlined, Interaction::Focused, true); + assert_eq!(error.line, ui.color(Role::Error)); + assert_eq!(error.label, ui.color(Role::Error)); + assert_eq!(error.supporting, ui.color(Role::Error)); + assert_eq!(error.stroke, 2.0, "错误线宽 2dp"); + assert_eq!(error.input, ui.color(Role::OnSurface), "ErrorInputColor"); +} + +/// 9.8:禁用内容 38%、Outlined 描边 12%、Filled 容器 12%(官方 4%,令牌缺口 → D10)。 +#[test] +fn disabled_uses_the_token_alphas() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = ui(flavor); + let container = interaction::disabled_colors(&ui, Role::Surface).0; + + let filled = palette(&ui, FieldVariant::Filled, Interaction::Disabled, false); + assert_eq!(filled.fill, Some(container)); + assert_eq!( + filled.input, + interaction::disabled_content_over_role(&ui, Role::OnSurface, true) + ); + + let outlined = palette(&ui, FieldVariant::Outlined, Interaction::Disabled, false); + assert_eq!(outlined.fill, None); + assert_eq!(outlined.line, container, "DisabledOutlineOpacity = 0.12"); + assert_eq!( + outlined.input, + interaction::disabled_content_over_role(&ui, Role::OnSurface, false) + ); + assert_ne!(outlined.input, ui.color(Role::OnSurface), "禁用不许满档"); + } +} + +/// Filled 有容器色、Outlined 没有;两者圆角都取 `Component::TextField`(4)。 +#[test] +fn filled_carries_the_container_role() { + let ui = ui(SchemeFlavor::Light); + assert_eq!( + palette(&ui, FieldVariant::Filled, Interaction::Enabled, false).fill, + Some(ui.color(Role::SurfaceContainerHighest)), + "FilledTextFieldTokens.ContainerColor" + ); + assert_eq!(FieldGeometry::of(&ui, true, None).shape.dp(), 4.0); +} diff --git a/crates/app-iced/src/components/inputs/geometry.rs b/crates/app-iced/src/components/inputs/geometry.rs new file mode 100644 index 0000000..fc7995d --- /dev/null +++ b/crates/app-iced/src/components/inputs/geometry.rs @@ -0,0 +1,309 @@ +//! 输入类家族的**内部几何**:能取令牌的一律取令牌,取不到的登记 G 编号后集中在这里。 +//! +//! ## 为什么这个文件存在 +//! +//! `components::gate` 的 `t2_no_size_literals_in_components` 扫 `.width(40.0)` 这类**调用点字面量**; +//! 但「TextField 高 56」这种 A 级参考值在 `LayoutMetrics` / `ComponentMetrics` 里确实没有落点 +//! (令牌层是另一条 mission 的 scope,不许我改)。所以按契约 §6.2:**登记 G 编号 + 只在这个文件里 +//! 定义一次**,组件实现一个数字都不写。 +//! +//! ## 参考值表(`[源]` = A1,本机 `material3-android:1.4.0` 的 token 源码;`[估]` = 团队口径) +//! +//! | G | 名称 | 参考值 | 出处 | 本文件落点 | +//! |---|---|---|---|---| +//! | G16 | TextField 可见高(带标签) | **56** | `TextFieldDefaults.MinHeight` / `OutlinedTextFieldTokens.ContainerHeight` | [`FIELD_HEIGHT`] | +//! | G17 | 聚焦/错误描边 | **2** | `OutlinedTextFieldTokens.FocusOutlineWidth`、`FilledTextFieldTokens.FocusActiveIndicatorHeight` | [`FOCUS_STROKE`] | +//! | G18 | Slider 轨道 / 竖条 / 停靠点 | **16 / 4×44 / 4** | `SliderTokens.ActiveTrackHeight` / `HandleWidth`+`HandleHeight` / `StopIndicatorSize` | [`TRACK_HEIGHT`] 等 | +//! | G19 | 菜单项高 | **48** | `Menu.kt:524 MenuListItemContainerHeight` | [`MENU_ITEM_HEIGHT`] | +//! | G20 | Search 宽高 | **min 360 / max 720 / 面板最小 240** | `SearchBar.kt:2712-2713`、`DockedExpandedTableMinHeight` | [`SEARCH_MIN_WIDTH`] 等 | +//! +//! **已经能取令牌、不许在这里重复定义的**(同一数值只许一份,见 `ComponentMetrics` 模块文档): +//! 搜索条高 56 = `LayoutMetrics::search_bar_height`;无标签单行 40 = `LayoutMetrics::control_height`; +//! 命中区 48 = `LayoutMetrics::touch_target`;水平/上下内边距与图标间距 = `Space::{X2,X3,X4}`; +//! 图标 24 = `IconSize::Toolbar`;圆角 = `ComponentSpec::shape`(TextField/Menu = 4,Search = Full, +//! SearchView = ExtraLarge 28);一像素线 = `stroke::HAIRLINE`。 +//! +//! ## 与文档的两处口径冲突(按「代码优先」处理,报告里有偏离登记) +//! +//! 1. `12b §8.1` 说菜单圆角「本项目 12(偏离官方 4)」;令牌表 `Component::Menu.spec().shape` +//! 是 `ExtraSmall`(**4**)。令牌层是 M69 定案的真源,本实现取 **4**,不取 12。 +//! 2. `12b §9.1` 说「无标签单行 40」是登记偏离——40 正好是 `LM.control_height`, +//! 所以那条偏离在实现上是「用骨架令牌」而不是「用字面量」。 + +use iced::Padding; + +use unlucky_design::component_map::Component; +use unlucky_design::icons::{IconKind, IconSize}; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; + +use crate::theme::{Ui, STROKE_PX}; + +/// G16:文本字段可见高(带浮动标签)。 +pub const FIELD_HEIGHT: f32 = 56.0; + +/// G17:聚焦/错误态的描边与指示线宽(官方 2dp;令牌层的 `stroke::HAIRLINE` 只有 1dp)。 +pub const FOCUS_STROKE: f32 = 2.0; + +/// G18:Slider 轨道高(激活/未激活同值)。 +pub const TRACK_HEIGHT: f32 = 16.0; + +/// G18:Slider 竖条 handle 宽(静息 / hover)。 +pub const HANDLE_WIDTH: f32 = 4.0; + +/// G18:Slider 竖条 handle 宽(focus / pressed)——A1 在这两态收窄到 2。 +pub const HANDLE_WIDTH_ACTIVE: f32 = 2.0; + +/// G18:Slider 竖条 handle 高(纵向溢出轨道,上下各 14)。 +pub const HANDLE_HEIGHT: f32 = 44.0; + +/// G18:离散停靠点直径。 +pub const STOP_SIZE: f32 = 4.0; + +/// G18:停靠点与轨道段之间的空隙(官方 `StopIndicatorTrailingSpace = 6`;6 不在 4px 栅格上)。 +pub const STOP_GAP: f32 = 6.0; + +/// G19:菜单项高。 +pub const MENU_ITEM_HEIGHT: f32 = 48.0; + +/// G20:搜索条最小宽(列宽不足时**不许压窄**,降级为 40 高 TextField 或 icon 入口)。 +pub const SEARCH_MIN_WIDTH: f32 = 360.0; + +/// G20:搜索条最大宽。 +pub const SEARCH_MAX_WIDTH: f32 = 720.0; + +/// G20:展开面板最小高(空结果也不许矮于它)。 +pub const SEARCH_VIEW_MIN_HEIGHT: f32 = 240.0; + +/// 文本字段的几何(Filled / Outlined 共用;差别只在颜色与形状)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FieldGeometry { + /// 可见高:带标签 [`FIELD_HEIGHT`],无标签 `LM.control_height`。 + pub height: f32, + /// 内容内边距(无图标 16/16;有前置图标 12/16,图标与文本间距另算)。 + pub padding: Padding, + /// 前置图标可视左边距(无偏移,就是内边距 12)。 + pub icon_inset: f32, + /// 图标 ↔ 文本间距(12:12 + 24 + 12 = 48 起文本,与官方 48 的图标槽一致)。 + pub icon_gap: f32, + /// 图标边长(24)。 + pub icon: f32, + /// 静息描边(一像素线)。 + pub stroke: f32, + /// 聚焦/错误描边(2)。 + pub focus_stroke: f32, + /// 圆角档位(`Component::TextField` = ExtraSmall)。 + pub shape: ShapeScale, +} + +impl FieldGeometry { + /// 求值;`labeled` = 带浮动标签(56),否则单行 40;`leading` = 有没有前置图标。 + pub fn of(ui: &Ui, labeled: bool, leading: Option) -> Self { + let icon_inset = ui.space(Space::X3); + let pad_end = ui.space(Space::X4); + let padding = if leading.is_some() { + Padding { + top: 0.0, + right: pad_end, + bottom: 0.0, + left: icon_inset, + } + } else { + Padding { + top: 0.0, + right: pad_end, + bottom: 0.0, + left: pad_end, + } + }; + + Self { + height: if labeled { + FIELD_HEIGHT + } else { + ui.metrics().control_height + }, + padding, + icon_inset, + icon_gap: ui.space(Space::X3), + icon: IconSize::Toolbar.px(), + stroke: STROKE_PX, + focus_stroke: FOCUS_STROKE, + shape: Component::TextField.spec().shape, + } + } +} + +/// Slider 的几何(轨道 / 竖条 / 停靠点 / 命中区)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SliderGeometry { + /// 轨道高(16)。 + pub track: f32, + /// 竖条宽(静息 4 / focus·pressed 2)。 + pub handle: f32, + /// 竖条高(44)。 + pub handle_tall: f32, + /// 停靠点直径(4)。 + pub stop: f32, + /// 控件高 = 命中区(`LM.touch_target` 48:竖条 44 + 上下各 2)。 + pub height: f32, +} + +impl SliderGeometry { + /// 从令牌求值。 + pub fn of(ui: &Ui) -> Self { + Self { + track: TRACK_HEIGHT, + handle: HANDLE_WIDTH, + handle_tall: HANDLE_HEIGHT, + stop: STOP_SIZE, + height: ui.metrics().touch_target, + } + } +} + +/// 菜单(Menu / DropdownMenuItem)的几何。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MenuGeometry { + /// 项高(48)。 + pub item_height: f32, + /// 水平内边距(12,来自 `Space::X3`)。 + pub pad_h: f32, + /// 上下留白(8,来自 `Space::X2`)。 + pub pad_v: f32, + /// 槽位图标边长(24)。 + pub icon: f32, + /// 图标 ↔ 文本间距(12)。 + pub gap: f32, + /// 容器圆角档位(`Component::Menu` = ExtraSmall 4)。 + pub shape: ShapeScale, +} + +impl MenuGeometry { + /// 从令牌求值。 + pub fn of(ui: &Ui) -> Self { + Self { + item_height: MENU_ITEM_HEIGHT, + pad_h: ui.space(Space::X3), + pad_v: ui.space(Space::X2), + icon: IconSize::Toolbar.px(), + gap: ui.space(Space::X3), + shape: Component::Menu.spec().shape, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + fn ui() -> Ui { + Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) + } + + /// G16 / G17:两个 2dp 与 56dp 的参考值钉在这里(改参考表会让这条红)。 + #[test] + fn field_geometry_matches_the_reference_table() { + let ui = ui(); + let labeled = FieldGeometry::of(&ui, true, None); + assert_eq!(labeled.height, 56.0, "G16 TextFieldDefaults.MinHeight"); + assert_eq!(labeled.stroke, 1.0, "静息一像素线"); + assert_eq!(labeled.focus_stroke, 2.0, "G17 FocusOutlineWidth"); + assert_eq!(labeled.shape.dp(), 4.0, "CornerExtraSmall"); + + // 无标签单行 40 = 骨架令牌,不是字面量 + let compact = FieldGeometry::of(&ui, false, None); + assert_eq!(compact.height, ui.metrics().control_height); + assert_eq!(compact.height, 40.0); + + // 内边距:无图标 16/16 + assert_eq!(compact.padding.left, 16.0); + assert_eq!(compact.padding.right, 16.0); + assert_eq!(compact.padding.left, compact.padding.right); + } + + /// 前置图标:图标可视边 12、文本从 48 起(= 12 + 24 + 12,官方 48 的图标槽)。 + #[test] + fn field_icon_slot_matches_the_official_48() { + let ui = ui(); + let leading = FieldGeometry::of(&ui, true, Some(IconKind::Search)); + assert_eq!(leading.icon, 24.0, "LeadingIconSize"); + assert_eq!(leading.icon_inset, 12.0, "textFieldHorizontalIconPadding"); + // 文本起点 = 图标可视边 12 + 图标 24 + 间距 12 = 48(官方图标槽) + assert_eq!(leading.icon_inset + leading.icon + leading.icon_gap, 48.0); + // 右侧仍是 16(官方内边距不因图标变) + assert_eq!(leading.padding.right, 16.0); + } + + /// G18:轨道 16 / 竖条 4×44 / 停靠点 4 / 命中 48;竖条比轨道高 28 ⇒ 上下各溢出 14。 + #[test] + fn slider_geometry_matches_the_reference_table() { + let ui = ui(); + let slider = SliderGeometry::of(&ui); + assert_eq!(slider.track, 16.0, "ActiveTrackHeight"); + assert_eq!(slider.handle, 4.0, "HandleWidth"); + assert_eq!(slider.handle_tall, 44.0, "HandleHeight"); + assert_eq!(slider.stop, 4.0, "StopIndicatorSize"); + assert_eq!( + (slider.handle_tall - slider.track) / 2.0, + 14.0, + "上下各溢出" + ); + assert_eq!(slider.height, 48.0, "命中下限 LM.touch_target"); + assert!(slider.height >= slider.handle_tall, "命中区必须容得下竖条"); + // focus / pressed 收窄到 2(A1 FocusHandleWidth / PressedHandleWidth) + assert_eq!(HANDLE_WIDTH_ACTIVE, 2.0); + assert!(HANDLE_WIDTH_ACTIVE < slider.handle); + } + + /// G19:项高 48、水平 12、上下 8、图标 24、圆角按令牌表(4,**不是** 12b 说的 12)。 + #[test] + fn menu_geometry_matches_the_reference_table() { + let ui = ui(); + let menu = MenuGeometry::of(&ui); + assert_eq!(menu.item_height, 48.0, "MenuListItemContainerHeight"); + assert_eq!(menu.pad_h, 12.0, "DropdownMenuItemHorizontalPadding"); + assert_eq!(menu.pad_v, 8.0, "DropdownMenuVerticalPadding"); + assert_eq!(menu.icon, 24.0); + assert_eq!( + menu.shape.dp(), + 4.0, + "MenuTokens.ContainerShape = CornerExtraSmall" + ); + assert_eq!( + menu.shape, + Component::Menu.spec().shape, + "圆角只能来自令牌表" + ); + assert_eq!(menu.gap, ui.space(Space::X3), "图标 ↔ 文本 12 走栅格令牌"); + } + + /// G20:搜索条 min 360 / max 720;面板最小高 240;条高取骨架令牌 56(**不在这里重复定义**)。 + #[test] + fn search_constants_do_not_duplicate_layout_metrics() { + let ui = ui(); + assert_eq!(SEARCH_MIN_WIDTH, 360.0); + assert_eq!(SEARCH_MAX_WIDTH, 720.0); + assert_eq!(SEARCH_VIEW_MIN_HEIGHT, 240.0); + assert_eq!(ui.metrics().search_bar_height, 56.0, "条高来自 LM"); + assert_eq!( + ShapeScale::Full.radius_px(ui.metrics().search_bar_height), + 28.0 + ); + assert_eq!(Component::Search.spec().shape, ShapeScale::Full); + // 图标可视边 = pad + offset(M69 的 SEARCH_BAR 不变量,本文件不重复定义 12/4) + let bar = Component::Search.metrics().expect("Search 有组件级度量"); + assert_eq!(bar.pad_start + bar.icon_offset, 16.0); + assert_eq!(bar.icon, 24.0); + } +} diff --git a/crates/app-iced/src/components/inputs/menu.rs b/crates/app-iced/src/components/inputs/menu.rs new file mode 100644 index 0000000..d4acbeb --- /dev/null +++ b/crates/app-iced/src/components/inputs/menu.rs @@ -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, + trailing: Option, + on_press: Option, + selected: bool, + state: Option, +} + +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>, + 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, + /// 文本色。 + 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::(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::(kind, colors.icon), + )); + } + row = row.push(label); + if let Some(kind) = item.trailing { + row = row.push(layout::fixed( + geometry.icon, + geometry.icon, + icons::view::(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; diff --git a/crates/app-iced/src/components/inputs/menu/tests.rs b/crates/app-iced/src/components/inputs/menu/tests.rs new file mode 100644 index 0000000..19f041b --- /dev/null +++ b/crates/app-iced/src/components/inputs/menu/tests.rs @@ -0,0 +1,123 @@ +//! menu 的单测(从 `menu.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; + +use crate::theme::Families; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) +} + +fn sample(flavor: SchemeFlavor) -> Element<'static, u8> { + let ui = Box::leak(Box::new(ui(flavor))); + let spec = Menu::new() + .item(MenuItem::new("重命名", 1_u8).leading(IconKind::Folder)) + .item( + MenuItem::new("复制实例", 2_u8) + .selected() + .trailing(IconKind::Check), + ) + .divider() + .item(MenuItem::disabled("删除").leading(IconKind::Trash)); + menu(ui, spec) +} + +/// 8.2:容器 `SurfaceContainer` + L2;阴影参数来自高程令牌。 +#[test] +fn container_uses_surface_container_and_level2_shadow() { + let ui = ui(SchemeFlavor::Light); + assert_eq!(Component::Menu.spec().elevation, Elevation::Level2); + assert_eq!( + Component::Menu.spec().elevation.surface_role(), + Role::SurfaceContainer, + "L2 的容器角色与 MenuTokens.ContainerColor 一致" + ); + let shadow = Elevation::Level2.shadow(); + assert!(shadow.is_visible(), "菜单是浮层,必须有可见阴影"); + assert_eq!(shadow.blur_px, 6.0); + assert_eq!(shadow.offset_y_px, 2.0); + // 容器色**不是** MaterialLayer::Overlay(那会高一档) + assert_ne!( + ui.layer(unlucky_design::elevation::MaterialLayer::Overlay), + ui.color(Role::SurfaceContainer) + ); +} + +/// 8.6:选中 = SecondaryContainer + OnSecondaryContainer;禁用内容 38%。 +#[test] +fn selected_and_disabled_items_use_the_right_roles() { + let ui = ui(SchemeFlavor::Light); + let selected = item_palette(&ui, Interaction::Enabled, true); + assert_eq!(selected.fill, Some(ui.color(Role::SecondaryContainer))); + assert_eq!(selected.ink, ui.color(Role::OnSecondaryContainer)); + assert_eq!(selected.icon, ui.color(Role::OnSecondaryContainer)); + + // 选中 + hover:状态层叠在 SecondaryContainer 上(不是满档底色) + let hovered = item_palette(&ui, Interaction::Hovered, true); + assert_ne!(hovered.fill, selected.fill); + assert_eq!( + hovered.fill, + interaction::overlay_color( + &ui, + Interaction::Hovered, + Role::OnSecondaryContainer, + Role::SecondaryContainer + ) + ); + + let disabled = item_palette(&ui, Interaction::Disabled, true); + assert_eq!(disabled.fill, None, "禁用项不留满档选中底色"); + assert_eq!( + disabled.ink, + interaction::disabled_content_over_role(&ui, Role::OnSurface, false) + ); + assert_ne!(disabled.ink, ui.color(Role::OnSurface)); +} + +/// 五态:hover 8% / pressed 10% / focus 10% 都叠在 SurfaceContainer 上(不透明)。 +#[test] +fn item_states_blend_on_the_menu_container() { + let ui = ui(SchemeFlavor::Light); + let rest = item_palette(&ui, Interaction::Enabled, false); + assert_eq!(rest.fill, None, "静息不叠色(菜单底色透出来)"); + for state in [ + Interaction::Hovered, + Interaction::Pressed, + Interaction::Focused, + ] { + let fill = item_palette(&ui, state, false) + .fill + .expect("可交互态必须给底色"); + assert_eq!(fill.a, 1.0, "结果必须不透明(D27)"); + assert_ne!(Some(fill), rest.fill); + } +} + +/// 8.3 / 8.4:项高 48、水平 12(内容)、选中指示器内缩 4 ⇒ 内容仍在 12。 +#[test] +fn item_geometry_matches_the_reference_table() { + let ui = ui(SchemeFlavor::Light); + let geometry = MenuGeometry::of(&ui); + assert_eq!(geometry.item_height, 48.0); + assert_eq!(geometry.pad_h, 12.0); + assert_eq!(geometry.pad_v, 8.0); + let inset = ui.space(Space::X1); + assert_eq!(inset, 4.0); + assert_eq!((geometry.pad_h - inset) + inset, 12.0, "内容落在 12 上"); +} + +/// 三种形态(带槽位 / 选中 / 禁用 / 分组线)都能构建。 +#[test] +fn menu_shapes_build() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let _: Element<'static, u8> = sample(flavor); + } +} diff --git a/crates/app-iced/src/components/inputs/mod.rs b/crates/app-iced/src/components/inputs/mod.rs index 451b95f..f3d9938 100644 --- a/crates/app-iced/src/components/inputs/mod.rs +++ b/crates/app-iced/src/components/inputs/mod.rs @@ -1,35 +1,40 @@ -//! **输入类家族(桩)**:由后续 mission 填充(§9 TextField / §20 Search / §11 Slider / §8 Menu)。 +//! **输入类家族**(D39 §1 的 `inputs/`):§9 TextField / §20 Search / §11 Slider / §8 Menu。 //! -//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**: -//! 家族模块已经在 `mod.rs` 里声明好,`gallery/inputs.rs` 也已经在 `gallery/mod.rs` 里声明好, -//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。 +//! 四个组件的共同纪律(与本目录外的契约一一对应): //! -//! 填充时请照抄按钮家族的分层: +//! - **对消息类型泛型**(`M: Clone`,不 import `crate::app::Message`)——组件库要能脱离业务验收; +//! - **`&'a Ui` 传入**,组件内部不 `Ui::new`(深浅热切靠外部重建整树); +//! - **不持有 `String`**:文本框与搜索条是受控组件(借值 + `on_input`/`on_change` 回传), +//! 滑块也只借一个比例; +//! - **hover/pressed/focus/disabled 一律走 [`super::interaction`]**(α 只有一处定义); +//! - **精确尺寸走 [`super::layout`]**(Q8/Q9 的两个固定解法),图标一律 `layout::fixed`; +//! - **零尺寸字面量**:能取令牌的取令牌,取不到的集中登记在 [`geometry`] 的 G16~G20。 //! -//! - 几何进 `inputs/geometry.rs`(数值从 `ComponentMetrics::SEARCH_BAR` / `Space` / `ShapeScale` 取, -//! 缺令牌登记 G 编号); -//! - 状态层只能走 [`crate::app::components::interaction`](α 一处定义); -//! - 精确尺寸用 [`crate::app::components::layout`](Q8/Q9 的解法),别手写 `.width(x)`; -//! - 受控组件签名收 `impl Fn(String) -> M`(契约 §2),组件内不持有 `String`。 +//! ## 文件分工 +//! +//! | 文件 | 内容 | +//! |---|---| +//! | [`geometry`] | 内部几何 + G 编号常量(`FieldGeometry` / `SliderGeometry` / `MenuGeometry`) | +//! | [`field`] | [`text_field`](Filled / Outlined) | +//! | [`search`] | [`search_bar`](收起)+ [`search_view`](展开 docked) | +//! | [`slider`] | [`slider`](自绘 `Widget`:轨道 16 + 竖条 4×44) | +//! | [`menu`] | [`menu`](`Menu` + `MenuItem` + 分组分隔线) | +//! +//! ## 与契约 §2 示例签名的关系 +//! +//! 契约示例是 `filled(ui, label, on_press)`;本家族的四个组件都比三个参数多(变体 × 五态 × +//! 前后槽位 × 错误/辅助文本),所以照按钮家族的先例把「部件参数」收进构造器结构体: +//! `text_field(ui, Field::new(value).label("名称"), on_input)`。**泛型纪律一字不改**, +//! 差别只是位置参数变命名参数(按钮家族已登记同一决定,见 `buttons` 模块文档)。 -use iced::widget::{container, text}; -use iced::Element; +pub mod geometry; -use unlucky_design::color::Role; -use unlucky_design::spacing::Space; -use unlucky_design::typography::TypeRole; +mod field; +mod menu; +mod search; +mod slider; -use crate::theme::Ui; - -/// 占位 demo(ASCII 文案,理由同 `selection::demo`)。 -pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> { - let style = ui.style(TypeRole::BodySmall); - container( - text("inputs · text field / search / slider / menu — pending") - .size(style.size_px) - .font(ui.font_of(style)) - .color(ui.color(Role::OnSurfaceVariant)), - ) - .padding(ui.space(Space::X2)) - .into() -} +pub use field::{text_field, Field, FieldVariant}; +pub use menu::{menu, Menu, MenuItem}; +pub use search::{search_bar, search_view, SearchBar, SearchView}; +pub use slider::{slider, Slider}; diff --git a/crates/app-iced/src/components/inputs/search.rs b/crates/app-iced/src/components/inputs/search.rs new file mode 100644 index 0000000..3d99951 --- /dev/null +++ b/crates/app-iced/src/components/inputs/search.rs @@ -0,0 +1,353 @@ +//! 搜索(`research/15` §20):官方是**两个组件**——[`search_bar`](收起)+ [`search_view`](展开)。 +//! +//! ## 参考值(`[源]` = A1;本机 `material3-android:1.4.0` 的 `SearchBarTokens` / `SearchBar.kt`) +//! +//! | # | 检查项 | 参考值 | 落点 | +//! |---|---|---|---| +//! | 20.1 | 高 + 圆角 | 高 **56**、圆角 **full = 28**(药丸,不是圆角矩形) | `LM.search_bar_height` + `Component::Search.spec().shape` | +//! | 20.2 | 宽 | min **360** / max **720**(G20:iced 无 min-width,只施加 max) | [`SEARCH_MAX_WIDTH`](super::geometry::SEARCH_MAX_WIDTH) | +//! | 20.3 | 容器色 / 高程 | `SurfaceContainerHigh` + **L0**(token 写 L3,默认值是 L0,按观感取 L0) | `Role::SurfaceContainerHigh` | +//! | 20.4 | 图标位 | leading 24/`OnSurface`、trailing 24/`OnSurfaceVariant`;**可视边 16 = pad 12 + offset 4** | `ComponentMetrics::SEARCH_BAR` | +//! | 20.5 | 文本 / 占位 / 聚焦 | 输入 `BodyLarge`+`OnSurface`;占位 `OnSurfaceVariant`;聚焦 `Secondary` 1px 指示 | `TypeRole` / `Role::Secondary` | +//! | 20.6 | docked 面板 | 头部 **56**、形状 `CornerExtraLarge` = **28** | `ShapeScale::ExtraLarge` | +//! | 20.8 | 头部 / 结果区分隔 | **1px `Outline`**(**不是** `OutlineVariant`!) | 见下 | +//! | 20.9 | 面板尺寸 | 顶距 8、最小高 **240**、docked 宽 **360** | §20B | +//! +//! ## 三处实现口径 +//! +//! 1. **图标可视边一律 `pad + icon_offset` 求和**(`SEARCH_BAR.pad_start = 12` + `icon_offset = 4` = 16)。 +//! M69 把这两个值钉成不变量,本文件**不写 16、也不写 12/4**——只做加法。 +//! 2. `20.8` 的分隔线用 `Role::Outline`,而 `components::divider` 给的是 `OutlineVariant` +//! (那是正文分隔线的角色)。两个角色在 A1 里明确不同,所以这里**不复用** `divider`, +//! 只按一像素线(`stroke::HAIRLINE`)画一条 —— 复用会把 20.8 判成「报 OutlineVariant 即错」。 +//! 3. bar 的 hover / pressed / disabled 在 `SearchBarTokens` 里**没有** token。 +//! hover/pressed 按通用状态层(`interaction::overlay_color`,8%/10%),disabled 按令牌的 +//! 12%/38%,都登记为 [估](D14 / D15);焦点指示用 A1 的 `FocusIndicatorColor = Secondary`。 + +use iced::widget::text::LineHeight; +use iced::widget::{column, container, row, text, text_input}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels}; + +use unlucky_design::color::Role; +use unlucky_design::component_map::{Component, ComponentMetrics}; +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, STROKE_PX}; + +use super::super::interaction::{self, Interaction}; +use super::super::layout; +use super::geometry::{SEARCH_MAX_WIDTH, SEARCH_MIN_WIDTH, SEARCH_VIEW_MIN_HEIGHT}; + +/// 搜索条(收起形态)。 +/// +/// 状态矩阵用 [`SearchBar::state`] 强制;运行期五态由 `Tracker`/`text_input` 的真实事件驱动。 +#[derive(Debug, Clone)] +pub struct SearchBar<'a> { + value: &'a str, + placeholder: &'a str, + trailing: Option, + state: Option, + width: Length, +} + +impl<'a> SearchBar<'a> { + /// 一条收起态搜索条(默认占位文案由页面给,组件内不拼中文)。 + pub fn new(value: &'a str) -> Self { + Self { + value, + placeholder: "", + trailing: Some(IconKind::Filter), + state: None, + width: Length::Fill, + } + } + + /// 占位文案。 + pub fn placeholder(mut self, placeholder: &'a str) -> Self { + self.placeholder = placeholder; + self + } + + /// 强制视觉态(gallery 取证)。 + pub fn state(mut self, state: Interaction) -> Self { + self.state = Some(state); + self + } + + /// 定宽(默认 `Fill`)。 + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } +} + +/// 搜索条内的一行内容(bar 与 view 头部共用,保证「展开时头部不跳」——20.6)。 +#[allow(clippy::too_many_arguments)] +fn bar_content<'a, M: Clone + 'a>( + ui: &'a Ui, + value: &'a str, + placeholder: &'a str, + trailing: Option, + state: Interaction, + on_input: impl Fn(String) -> M + 'a, +) -> Element<'a, M> { + // 20.4:可视边 = pad + offset(**求和**,不抄 16) + let bar: ComponentMetrics = Component::Search.metrics().expect("Search 有组件级度量"); + // 20.4:图标**可视边** = pad + offset(M69 不变量)。这里反解出偏移,两侧共用同一个量。 + let icon_edge = bar.pad_start + bar.icon_offset; + let icon_offset = icon_edge - bar.pad_start; + + let ink = if state.is_disabled() { + interaction::disabled_content_over_role(ui, Role::OnSurface, true) + } else { + ui.color(Role::OnSurface) + }; + let subtle = if state.is_disabled() { + ink + } else { + ui.color(Role::OnSurfaceVariant) + }; + + let body_style = ui.style(TypeRole::BodyLarge); + let input = text_input(placeholder, value) + .on_input_maybe(if state.is_disabled() { + None + } else { + Some(on_input) + }) + .size(body_style.size_px) + .line_height(LineHeight::Absolute(Pixels(body_style.line_height_px))) + .font(ui.font_of(body_style)) + .padding(Padding::default()) + .width(Length::Fill) + .style(move |_theme, _status| text_input::Style { + background: Background::Color(Color::TRANSPARENT), + border: Border::default(), + icon: subtle, + placeholder: subtle, + value: ink, + selection: ink, + }); + + // 前置图标:左边 = pad(容器内边距 12)+ offset(4)= 16。两层分别来自令牌的两个字段。 + let leading = container(layout::fixed( + bar.icon, + bar.icon, + icons::view::(IconKind::Search, ink), + )) + .padding(Padding { + top: 0.0, + right: 0.0, + bottom: 0.0, + left: icon_offset, + }); + + let mut contents = row![leading, input] + .align_y(Alignment::Center) + .spacing(bar.gap); + + if let Some(kind) = trailing { + // 尾随图标:右边 = pad_end + offset(镜像同式),所以右缘也在 16 上 + let trailing = container(layout::fixed( + bar.icon, + bar.icon, + icons::view::(kind, subtle), + )) + .padding(Padding { + top: 0.0, + right: icon_offset, + bottom: 0.0, + left: 0.0, + }); + contents = contents.push(trailing); + } + + container(contents) + .width(Length::Fill) + .height(Length::Fill) + .align_y(Alignment::Center) + .padding(Padding { + top: 0.0, + right: bar.pad_end, + bottom: 0.0, + left: bar.pad_start, + }) + .into() +} + +/// 收起态搜索条(56 高、药丸、`SurfaceContainerHigh`、无可见阴影)。 +pub fn search_bar<'a, M: Clone + 'a>( + ui: &'a Ui, + bar: SearchBar<'a>, + on_input: impl Fn(String) -> M + 'a, +) -> Element<'a, M> { + let state = bar.state.unwrap_or(Interaction::Enabled); + let component = Component::Search.spec(); + let height = ui.metrics().search_bar_height; + + let background = if state.is_disabled() { + interaction::disabled_colors(ui, Role::Surface).0 + } else { + let role = Role::SurfaceContainerHigh; + interaction::overlay_color(ui, state, Role::OnSurface, role).unwrap_or(ui.color(role)) + }; + + // 20.5:聚焦指示 = 1px `Secondary`(A1 的 token 存在但 Compose 未消费 → 标 [规格]) + let focus_ring = matches!(state, Interaction::Focused); + let border = Border { + color: if focus_ring { + ui.color(Role::Secondary) + } else { + Color::TRANSPARENT + }, + width: if focus_ring { STROKE_PX } else { 0.0 }, + radius: ui.radius(component.shape, height), + }; + + container(bar_content( + ui, + bar.value, + bar.placeholder, + bar.trailing, + state, + on_input, + )) + .width(bar.width) + // 20.2:max **720**(`SearchBarMaxWidth`)。min 360 无法在 iced 表达(G20),由页面保证列宽。 + .max_width(SEARCH_MAX_WIDTH) + .height(Length::Fixed(height)) + .style(move |_theme| container::Style { + background: Some(Background::Color(background)), + border, + ..container::Style::default() + }) + .into() +} + +/// 展开态搜索面板(docked 形态:桌面 1180 窗口永远用它,`20C` 的结论)。 +/// +/// 形状:头部 56(半径 28)+ 1px `Outline` 分隔 + 结果区(最小 240)。 +#[derive(Debug, Clone)] +pub struct SearchView<'a> { + value: &'a str, + placeholder: &'a str, + empty: &'a str, + results: &'a [&'a str], + width: f32, +} + +impl<'a> SearchView<'a> { + /// 一个展开面板(`empty` = 空结果文案,由页面给 i18n 文本)。 + pub fn new(value: &'a str, results: &'a [&'a str], empty: &'a str) -> Self { + Self { + value, + placeholder: "", + empty, + results, + width: SEARCH_MIN_WIDTH, + } + } + + /// 占位文案。 + pub fn placeholder(mut self, placeholder: &'a str) -> Self { + self.placeholder = placeholder; + self + } +} + +/// 展开态搜索面板。 +pub fn search_view<'a, M: Clone + 'a>( + ui: &'a Ui, + view: SearchView<'a>, + on_input: impl Fn(String) -> M + 'a, +) -> Element<'a, M> { + let panel = ui.color(Role::SurfaceContainerHigh); + let header_height = ui.metrics().search_bar_height; + let body_style = ui.style(TypeRole::BodyMedium); + + // 20.8:头部/结果区分隔是 **`Outline`**(正文分隔线才是 OutlineVariant) + let divider = container(iced::widget::space::horizontal()) + .width(Length::Fill) + .height(Length::Fixed(STROKE_PX)) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::Outline))), + ..container::Style::default() + }); + + let mut rows = column![].width(Length::Fill).spacing(0); + for result in view.results { + rows = rows.push( + container( + text(*result) + .size(body_style.size_px) + .font(ui.font_of(body_style)) + .color(ui.color(Role::OnSurface)), + ) + .width(Length::Fill) + .padding(Padding { + top: ui.space(Space::X2), + right: ui.space(Space::X4), + bottom: ui.space(Space::X2), + left: ui.space(Space::X4), + }), + ); + } + if view.results.is_empty() { + let small = ui.style(TypeRole::BodySmall); + rows = rows.push( + container( + text(view.empty) + .size(small.size_px) + .font(ui.font_of(small)) + .color(ui.color(Role::OnSurfaceVariant)), + ) + .width(Length::Fill) + .padding(Padding { + top: ui.space(Space::X4), + right: ui.space(Space::X4), + bottom: ui.space(Space::X4), + left: ui.space(Space::X4), + }), + ); + } + + let body = container(rows) + .width(Length::Fill) + .height(Length::Fixed(SEARCH_VIEW_MIN_HEIGHT)) + .align_y(Alignment::Start); + + let panel_content = column![ + container(bar_content( + ui, + view.value, + view.placeholder, + Some(IconKind::Close), + Interaction::Enabled, + on_input, + )) + .width(Length::Fill) + .height(Length::Fixed(header_height)), + divider, + body, + ]; + + container(panel_content) + .width(Length::Fixed(view.width)) + .style(move |_theme| container::Style { + background: Some(Background::Color(panel)), + // 20.6:docked 面板 = CornerExtraLarge(28);bar ⇄ view 半径不变(§20C) + border: Border { + radius: ui.radius_dp(ShapeScale::ExtraLarge), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/inputs/search/tests.rs b/crates/app-iced/src/components/inputs/search/tests.rs new file mode 100644 index 0000000..b635884 --- /dev/null +++ b/crates/app-iced/src/components/inputs/search/tests.rs @@ -0,0 +1,93 @@ +//! search 的单测(从 `search.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; + +use crate::theme::Families; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) +} + +fn leaked(flavor: SchemeFlavor) -> &'static Ui { + Box::leak(Box::new(ui(flavor))) +} + +/// **M69 不变量**:图标可视边 = pad + offset(12 + 4 = 16),且本实现只做加法。 +#[test] +fn icon_edge_is_pad_plus_offset_not_a_literal() { + let bar = Component::Search.metrics().expect("Search 有组件级度量"); + assert_eq!(bar.pad_start, 12.0, "M69 钉住的 pad_start"); + assert_eq!(bar.icon_offset, 4.0, "M69 钉住的 icon_offset"); + assert_eq!(bar.pad_start + bar.icon_offset, 16.0); + assert_eq!(bar.icon, 24.0, "SearchBarTokens 图标 24"); + // 文本从 pad(12)起:图标(16..40)+ gap 8 → 48 是文字的实际起点 + assert_eq!( + bar.pad_start + bar.icon_offset + bar.icon + bar.gap, + 48.0, + "图标槽 + 间距后文字起点 48" + ); +} + +/// 20.1:高 56、圆角 full = 28(药丸);条高来自 LM,不重复定义。 +#[test] +fn bar_height_and_radius_come_from_tokens() { + let ui = ui(SchemeFlavor::Light); + assert_eq!(ui.metrics().search_bar_height, 56.0); + assert_eq!( + ShapeScale::Full.radius_px(ui.metrics().search_bar_height), + 28.0, + "SearchBarCornerRadius = InputFieldHeight / 2" + ); + assert_eq!(Component::Search.spec().shape, ShapeScale::Full); + // 高程按观感取 L0(无可见阴影) + assert_eq!( + Component::Search.spec().elevation, + unlucky_design::elevation::Elevation::Level0 + ); +} + +/// 20.6 / 20.9:面板头部 56、形状 ExtraLarge(28)、最小高 240、docked 宽 360。 +#[test] +fn docked_panel_geometry() { + let ui = ui(SchemeFlavor::Light); + assert_eq!(ShapeScale::ExtraLarge.dp(), 28.0, "DockedContainerShape"); + assert_eq!( + SEARCH_VIEW_MIN_HEIGHT, 240.0, + "DockedExpandedTableMinHeight" + ); + assert_eq!(SEARCH_MIN_WIDTH, 360.0, "SearchBarMinWidth = docked 面板宽"); + assert_eq!(ui.metrics().search_bar_height, 56.0, "头部与 bar 同高"); +} + +/// bar 与 view 都能构建(含禁用态:输入被摘掉)。 +#[test] +fn both_forms_build() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + for state in Interaction::ALL { + let ui = leaked(flavor); + let bar = SearchBar::new("").placeholder("搜索").state(state); + let _: Element<'_, u8> = search_bar(ui, bar, |_| 0_u8); + + let results: &[&str] = &["实例一", "1.21.4"]; + let view = SearchView::new("实例", results, "没有结果").placeholder("搜索"); + let _: Element<'_, u8> = search_view(ui, view, |_| 0_u8); + } + } +} + +/// 空结果也必须给面板内容(20C:不许收回到 bar)。 +#[test] +fn empty_results_still_render_a_panel() { + let ui = leaked(SchemeFlavor::Light); + let empty: &[&str] = &[]; + let view = SearchView::new("xyz", empty, "没有结果"); + let _: Element<'_, u8> = search_view(ui, view, |_| 0_u8); +} diff --git a/crates/app-iced/src/components/inputs/slider.rs b/crates/app-iced/src/components/inputs/slider.rs new file mode 100644 index 0000000..91449a2 --- /dev/null +++ b/crates/app-iced/src/components/inputs/slider.rs @@ -0,0 +1,396 @@ +//! 滑块(`research/12b` §11,A1):**自绘 `Widget`**——M3 Expressive 的滑块是「竖条」,不是圆点。 +//! +//! ## 参考值(`[源]` = A1,`SliderTokens`) +//! +//! | # | 检查项 | 参考值 | 落点 | +//! |---|---|---|---| +//! | 11.1 | 轨道高 | **16**,圆角 full(= 8) | `TRACK_HEIGHT`(G18) | +//! | 11.2 | handle 形态 | **4 宽 × 44 高**,圆角 full;focus / pressed 收窄到 **2** | `HANDLE_WIDTH` / `HANDLE_HEIGHT` | +//! | 11.3 | 颜色 | 激活轨 `Primary`、未激活轨 `SecondaryContainer`、handle `Primary` | `Role::*` | +//! | 11.4 | handle 与轨道 | 纵向溢出:上下各 **14**((44−16)/2) | 推导 | +//! | 11.5 | 命中高度 | **48**(`LM.touch_target`;竖条 44 + 上下各 2) | `SliderGeometry::height` | +//! | 11.6 | 禁用 | 激活轨 `OnSurface`@**38%**、未激活 @**12%**、handle @**38%** | `interaction::*` | +//! | — | 离散停靠点 | **4** 圆点 `SecondaryContainer`(`StopIndicatorSize`);尾随间距 6 | [`Slider::steps`] | +//! +//! ## 为什么必须自绘 +//! +//! iced 内置 `slider` 只能画「轨道 + 圆形/矩形 handle(高度 = 控件高)」, +//! **没有停靠点、没有禁用态、无法强制取证态**;而 §11.2 的 4×44 竖条要求 handle 高(44) +//! 与轨道高(16)分离。所以这里用 `advanced::Widget` 自绘:布局只有「宽 × 48」一个原子, +//! 轨道/激活段/停靠点/竖条全部用 `fill_quad` 画进同一个 bounds——几何因此在像素级可断言。 +//! +//! ## 状态层([估],登记 D16) +//! +//! `SliderTokens` **没有**状态层直径(M3 Expressive 去掉了旧版的水滴 halo),hover/pressed 只体现为 +//! handle 宽度与颜色。为了让五态在验收图上可辨且 α 只认令牌,这里把**通用状态层** +//! (`interaction::overlay_color`:hover 8% / focus 10% / pressed 10%)叠在 handle 上, +//! 宽度仍按 A1 收窄——两件事都来自令牌,没有新数值。 +//! +//! ## 拖动 +//! +//! `on_change` 有值就启用拖动与点击跳转;`None` = 禁用(也不吃事件)。 +//! 值由外部持有(受控组件),拖动只 `publish` 新比例:组件不持有业务状态(契约 §2)。 + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::renderer::Quad; +use iced::advanced::widget::{tree, Tree}; +use iced::advanced::{mouse, Clipboard, Shell, Widget}; +use iced::{Background, Border, Color, Element, Event, Length, Rectangle, Size}; + +use unlucky_design::color::Role; + +use crate::theme::Ui; + +use super::super::interaction::{self, Interaction, Tracker}; +use super::geometry::{SliderGeometry, HANDLE_HEIGHT, HANDLE_WIDTH_ACTIVE, STOP_GAP, STOP_SIZE}; + +mod track; + +pub use track::TrackGeometry; + +use track::{fraction_at, quantize, EPSILON}; + +/// 一个滑块(受控:`fraction` 由外部给,变化经 `on_change` 回传)。 +pub struct Slider<'a, M> { + ui: &'a Ui, + fraction: f32, + steps: Option, + on_change: Option M + 'a>>, + forced: Option, +} + +impl<'a, M> Slider<'a, M> { + /// 连续值滑块(0.0 ~= 1.0)。 + pub fn new(ui: &'a Ui, fraction: f32) -> Self { + Self { + ui, + fraction: fraction.clamp(0.0, 1.0), + steps: None, + on_change: None, + forced: None, + } + } + + /// 离散值:`steps` = 分段数(4 → 5 个停靠点 0 / 25 / 50 / 75 / 100%)。 + pub fn steps(mut self, steps: u32) -> Self { + self.steps = Some(steps.max(1)); + self.fraction = quantize(self.fraction, self.steps); + self + } + + /// 值变化回调;不给 = 禁用。 + pub fn on_change(mut self, on_change: impl Fn(f32) -> M + 'a) -> Self { + self.on_change = Some(Box::new(on_change)); + self + } + + /// 强制视觉态(gallery 状态矩阵取证)。 + pub fn state(mut self, state: Interaction) -> Self { + self.forced = Some(state); + self + } + + /// 当前比例(量化后)。 + fn value(&self) -> f32 { + quantize(self.fraction, self.steps) + } + + /// 禁用与否:没有回调就是禁用(与按钮 `on_press: Option` 同一口径)。 + fn enabled(&self) -> bool { + self.on_change.is_some() && !matches!(self.forced, Some(Interaction::Disabled)) + } +} + +/// `Widget` 的状态:五态状态机([`Tracker`])+ 是否正在拖动。 +#[derive(Debug, Default)] +struct State { + tracker: Tracker, + dragging: bool, +} + +/// 一个圆角实心块(轨道的所有部件都是它)。 +fn quad(bounds: Rectangle, radius: f32, color: Color) -> (Quad, Background) { + ( + Quad { + bounds, + border: Border { + radius: radius.into(), + ..Border::default() + }, + ..Quad::default() + }, + Background::Color(color), + ) +} + +impl<'a, M, Theme, Renderer> Widget for Slider<'a, M> +where + M: Clone, + Renderer: iced::advanced::Renderer, +{ + fn size(&self) -> Size { + Size { + width: Length::Fill, + height: Length::Fixed(SliderGeometry::of(self.ui).height), + } + } + + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State { + tracker: Tracker::new(self.enabled()), + dragging: false, + }) + } + + fn layout( + &mut self, + _tree: &mut Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::atomic( + limits, + Length::Fill, + Length::Fixed(SliderGeometry::of(self.ui).height), + ) + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, M>, + _viewport: &Rectangle, + ) { + if self.forced.is_some() { + return; + } + + let enabled = self.enabled(); + let geometry = SliderGeometry::of(self.ui); + let bounds = layout.bounds(); + let state = tree.state.downcast_mut::(); + if state.tracker.set_enabled(enabled) { + shell.request_redraw(); + } + + let mut target: Option = None; + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + if enabled && cursor.is_over(bounds) => + { + if let Some(position) = cursor.position() { + state.dragging = true; + target = Some(fraction_at(bounds, position.x, geometry.handle)); + shell.capture_event(); + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { + if let Some(position) = cursor.position() { + target = Some(fraction_at(bounds, position.x, geometry.handle)); + shell.capture_event(); + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + state.dragging = false; + } + _ => {} + } + + if let Some(fraction) = target { + let next = quantize(fraction, self.steps); + if (next - self.value()).abs() >= EPSILON { + if let Some(on_change) = &self.on_change { + self.fraction = next; + shell.publish(on_change(next)); + } + } + } + + if state.tracker.event(event, cursor.is_over(bounds)) { + shell.request_redraw(); + } + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + _theme: &Theme, + _style: &iced::advanced::renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let ui = self.ui; + let geometry = SliderGeometry::of(ui); + let bounds = layout.bounds(); + let state = self + .forced + .unwrap_or_else(|| tree.state.downcast_ref::().tracker.state()); + let disabled = state.is_disabled(); + + let (active_color, inactive_color, handle_color, handle_width) = if disabled { + ( + interaction::disabled_content_over_role(ui, Role::OnSurface, false), + interaction::disabled_colors(ui, Role::Surface).0, + interaction::disabled_content_over_role(ui, Role::OnSurface, false), + geometry.handle, + ) + } else { + // 状态层只叠在 handle 上(D16):α 全部来自 interaction(hover 8 / focus 10 / pressed 10) + let handle_color = + interaction::overlay_color(ui, state, Role::OnSurface, Role::Primary) + .unwrap_or_else(|| ui.color(Role::Primary)); + let handle_width = match state { + Interaction::Focused | Interaction::Pressed => HANDLE_WIDTH_ACTIVE, + _ => geometry.handle, + }; + ( + ui.color(Role::Primary), + ui.color(Role::SecondaryContainer), + handle_color, + handle_width, + ) + }; + + let centre_y = bounds.y + bounds.height / 2.0; + let track_x = bounds.x; + let track_w = bounds.width; + let track = TrackGeometry::of(bounds, self.value()); + let centre_x = track.handle_centre(); + let track_rect = |x: f32, width: f32| Rectangle { + x, + y: centre_y - geometry.track / 2.0, + width: width.max(0.0), + height: geometry.track, + }; + + // 离散停靠点:A1 的 `StopIndicatorColor = SecondaryContainer` 与未激活轨**同色**, + // 所以停靠点只有在「未激活轨在它两侧留出空隙」时才读得出来(官方也是这么画的: + // `StopIndicatorSize = 4` + `StopIndicatorTrailingSpace = 6`)。空隙两侧各留半个点 + 6。 + // **不含两端**:0% 与 100% 上不画停靠点(那是轨道自己的圆角端,官方也不画) + let stops: Vec = match self.steps { + Some(steps) if steps >= 2 => (1..steps) + .map(|index| TrackGeometry::of(bounds, index as f32 / steps as f32).handle_centre()) + .collect(), + _ => Vec::new(), + }; + let gap = STOP_SIZE / 2.0 + STOP_GAP; + + // 未激活轨:从**竖条中心**到右端,按停靠点切成段(每段自己圆角,端点读数更清楚) + let mut cursor = centre_x; + let mut segments: Vec<(f32, f32)> = Vec::new(); + for anchor in stops.iter().copied() { + if anchor <= cursor { + continue; + } + segments.push((cursor, anchor - gap - cursor)); + cursor = anchor + gap; + } + if track_x + track_w > cursor { + segments.push((cursor, track_x + track_w - cursor)); + } + for (x, width) in segments { + if width <= 0.0 { + continue; + } + let (inactive, inactive_bg) = + quad(track_rect(x, width), geometry.track / 2.0, inactive_color); + renderer.fill_quad(inactive, inactive_bg); + } + + // 激活轨:从容器左缘到**竖条中心**(与官方一致:激活段托在竖条下面) + let active_w = track.active_width; + if active_w > 0.0 { + let (active, active_bg) = quad( + track_rect(track_x, active_w), + geometry.track / 2.0, + active_color, + ); + renderer.fill_quad(active, active_bg); + } + + // 停靠点本体:4dp 圆点,只画在**未激活侧**(激活段已经走过的不重复画) + if !disabled { + for anchor in stops + .iter() + .copied() + .filter(|anchor| *anchor > centre_x + EPSILON) + { + let (stop, stop_bg) = quad( + Rectangle { + x: anchor - STOP_SIZE / 2.0, + y: centre_y - STOP_SIZE / 2.0, + width: STOP_SIZE, + height: STOP_SIZE, + }, + STOP_SIZE / 2.0, + inactive_color, + ); + renderer.fill_quad(stop, stop_bg); + } + } + + // 竖条:4×44(focus / pressed 收窄到 2),圆角 full + let (bar, bar_bg) = quad( + Rectangle { + x: track.handle_centre() - handle_width / 2.0, + y: centre_y - HANDLE_HEIGHT / 2.0, + width: handle_width, + height: HANDLE_HEIGHT, + }, + handle_width / 2.0, + handle_color, + ); + renderer.fill_quad(bar, bar_bg); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + if !self.enabled() { + return mouse::Interaction::None; + } + if tree.state.downcast_ref::().dragging { + return mouse::Interaction::Grabbing; + } + if cursor.is_over(layout.bounds()) { + mouse::Interaction::Grab + } else { + mouse::Interaction::None + } + } +} + +impl<'a, M, Theme, Renderer> From> for Element<'a, M, Theme, Renderer> +where + M: Clone + 'a, + Theme: 'a, + Renderer: iced::advanced::Renderer + 'a, +{ + fn from(slider: Slider<'a, M>) -> Self { + Element::new(slider) + } +} + +/// 便捷入口:把 [`Slider`] 直接变成 `Element`(与其它组件的函数式签名一致)。 +pub fn slider<'a, M: Clone + 'a>(spec: Slider<'a, M>) -> Element<'a, M> { + Element::new(spec) +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/inputs/slider/tests.rs b/crates/app-iced/src/components/inputs/slider/tests.rs new file mode 100644 index 0000000..482868d --- /dev/null +++ b/crates/app-iced/src/components/inputs/slider/tests.rs @@ -0,0 +1,122 @@ +//! slider 的单测(从 `slider.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; + +use crate::theme::Families; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui() -> Ui { + Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) +} + +fn track() -> Rectangle { + Rectangle { + x: 100.0, + y: 200.0, + width: 300.0, + height: 48.0, + } +} + +/// 11.1 / 11.2 / 11.4:轨道 16、竖条 4×44、上下各溢出 14。 +#[test] +fn track_and_handle_are_the_m3_expressive_shapes() { + let g = SliderGeometry::of(&ui()); + assert_eq!(g.track, 16.0); + assert_eq!((g.handle, g.handle_tall), (4.0, 44.0)); + assert_eq!((g.handle_tall - g.track) / 2.0, 14.0); + assert_eq!(g.stop, 4.0); + assert_eq!(g.height, 48.0, "命中区 = LM.touch_target"); +} + +/// 0% / 50% / 100% 三档的位置:竖条**永不越界**,激活段与轨道分界一致。 +#[test] +fn thumb_stays_inside_the_track_at_both_ends() { + let bounds = track(); + let zero = TrackGeometry::of(bounds, 0.0); + assert_eq!(zero.handle_left, bounds.x, "0% 时竖条左缘 = 轨道左缘"); + assert!(zero.handle_left >= bounds.x); + assert_eq!(zero.active_width, 2.0, "0% 时激活段只剩竖条半宽"); + + let half = TrackGeometry::of(bounds, 0.5); + assert!((half.handle_left - (bounds.x + bounds.width / 2.0 - 2.0)).abs() < 0.01); + + let full = TrackGeometry::of(bounds, 1.0); + assert_eq!( + full.handle_right, + bounds.x + bounds.width, + "100% 时右缘贴轨道右缘" + ); + assert!(full.handle_right <= bounds.x + bounds.width); + // 激活段画到**竖条中心**为止(= 右缘 − 2),不是画到右缘:100% 时也被竖条自己盖住 + assert_eq!(full.active_width, bounds.width - 2.0); +} + +/// 越界的比例(<0 或 >1)被 clamp,不会把竖条推到轨道外。 +#[test] +fn out_of_range_fractions_are_clamped() { + let bounds = track(); + for fraction in [-3.0, -0.01, 0.0, 1.0, 1.5, 9.0] { + let geometry = TrackGeometry::of(bounds, fraction); + assert!( + geometry.handle_left >= bounds.x, + "{fraction} 的竖条左缘越界:{}", + geometry.handle_left + ); + assert!( + geometry.handle_right <= bounds.x + bounds.width, + "{fraction} 的竖条右缘越界:{}", + geometry.handle_right + ); + } +} + +/// 离散值:4 段 → 1/4 步进;连续值原样。 +#[test] +fn discrete_sliders_quantize_to_the_stops() { + assert_eq!(quantize(0.3, Some(4)), 0.25); + assert_eq!(quantize(0.4, Some(4)), 0.5); + assert_eq!(quantize(1.4, Some(4)), 1.0); + assert_eq!(quantize(0.3, None), 0.3); + // 停靠点位置 = 五等分,且都落在轨道内 + let bounds = track(); + for index in 0..=4 { + let geometry = TrackGeometry::of(bounds, index as f32 / 4.0); + assert!(geometry.handle_left >= bounds.x); + assert!(geometry.handle_right <= bounds.x + bounds.width); + } +} + +/// 拖动换算:指针在轨道左缘 → 0、右缘 → 1,中间线性。 +#[test] +fn fraction_at_is_the_inverse_of_handle_center() { + let bounds = track(); + for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] { + let centre = TrackGeometry::of(bounds, fraction).handle_centre(); + let back = fraction_at(bounds, centre, 4.0); + assert!((back - fraction).abs() < 1e-4, "{fraction} → {back}"); + } + assert_eq!(fraction_at(bounds, bounds.x, 4.0), 0.0); + assert_eq!(fraction_at(bounds, bounds.x + bounds.width, 4.0), 1.0); +} + +/// 没有回调 = 禁用:不吃拖动,也不响应状态机。 +#[test] +fn slider_without_a_callback_is_disabled() { + let ui = ui(); + let slider: Slider<'_, u8> = Slider::new(&ui, 0.5); + assert!(!slider.enabled()); + let enabled: Slider<'_, u8> = Slider::new(&ui, 0.5).on_change(|_| 0_u8); + assert!(enabled.enabled()); + let forced: Slider<'_, u8> = Slider::new(&ui, 0.5) + .on_change(|_| 0_u8) + .state(Interaction::Disabled); + assert!(!forced.enabled(), "强制禁用时不吃事件"); +} diff --git a/crates/app-iced/src/components/inputs/slider/track.rs b/crates/app-iced/src/components/inputs/slider/track.rs new file mode 100644 index 0000000..d160746 --- /dev/null +++ b/crates/app-iced/src/components/inputs/slider/track.rs @@ -0,0 +1,66 @@ +//! Slider 的**纯几何**(无 iced 画布依赖):比例 ⇄ 像素,`draw` 与单测共用同一条公式。 +//! +//! 单独成文件的原因:`slider.rs` 已到 400 行红线,而这几条公式是「0% / 100% 竖条不越界」的 +//! 唯一实现点——独立出来才好被人一眼审完。`draw` 与 `#[cfg(test)] mod tests` 都调这里。 + +use iced::Rectangle; + +use super::super::geometry::HANDLE_WIDTH; + +/// 比例比较的容差(浮点拖动会产生 1e-7 级抖动,别让它每帧发一条消息)。 +pub(super) const EPSILON: f32 = 1e-4; + +/// 量化到停靠点(连续值时原样返回)。 +pub(super) fn quantize(fraction: f32, steps: Option) -> f32 { + match steps { + Some(steps) => { + let steps = steps as f32; + (fraction.clamp(0.0, 1.0) * steps).round() / steps + } + None => fraction.clamp(0.0, 1.0), + } +} + +/// 一条竖条的横坐标(**位移区间是 `handle/2 .. width - handle/2`**,所以 0%/100% 都不越界)。 +fn handle_center(bounds: Rectangle, fraction: f32, handle: f32) -> f32 { + let travel = (bounds.width - handle).max(0.0); + bounds.x + handle / 2.0 + travel * fraction.clamp(0.0, 1.0) +} + +/// 指针横坐标 → 比例(`handle_center` 的逆函数,同样不越界)。 +pub(super) fn fraction_at(bounds: Rectangle, x: f32, handle: f32) -> f32 { + let travel = (bounds.width - handle).max(1.0); + ((x - bounds.x - handle / 2.0) / travel).clamp(0.0, 1.0) +} + +/// 轨道 / 停靠点 / 竖条的纯几何(像素级断言用;`Widget::draw` 与测试共用同一条公式)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TrackGeometry { + /// 激活段的左缘。 + pub active_left: f32, + /// 激活段的宽。 + pub active_width: f32, + /// 竖条左缘(`f = 0` 时应 = 轨道左缘)。 + pub handle_left: f32, + /// 竖条右缘(`f = 1` 时应 = 轨道右缘)。 + pub handle_right: f32, +} + +impl TrackGeometry { + /// 按轨道矩形与比例求值(`f` 会被 clamp 到 0..=1)。 + pub fn of(track: Rectangle, fraction: f32) -> Self { + let centre = handle_center(track, fraction, HANDLE_WIDTH); + let handle_left = centre - HANDLE_WIDTH / 2.0; + Self { + active_left: track.x, + active_width: centre - track.x, + handle_left, + handle_right: handle_left + HANDLE_WIDTH, + } + } + + /// 竖条中心(= 激活段与未激活段的分界)。 + pub fn handle_centre(&self) -> f32 { + self.handle_left + HANDLE_WIDTH / 2.0 + } +}