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..9dc7050 --- /dev/null +++ b/crates/app-iced/src/components/inputs/menu.rs @@ -0,0 +1,354 @@ +//! 菜单(`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 { + // **撑开**:label 与 trailing 之间插一个水平 Fill,trailing 才会被推到行右内边距上 + // (M3 里 trailing 是「快捷键 / 子菜单箭头 / 勾」,必须在一列菜单里成一条竖线)。 + // 没有 trailing 时**不插**:label 仍在左边,不会被居中或右对齐。 + row = row.push(iced::widget::space::horizontal()); + row = row.push(layout::fixed( + geometry.icon, + geometry.icon, + icons::view::(kind, colors.icon), + )); + } + + // 行必须撑满 item 宽度:上面的 Fill 空间要有宽度可撑(Q8 的教训:撑开靠容器,不靠子元素)。 + // 顺带把内容垂直居中——`button` 只把内容放在 padding-box 的左上角。 + let content = iced::widget::container(row) + .width(Length::Fill) + .height(Length::Fill) + .align_y(Alignment::Center); + + let selected = item.selected; + let style = move |_theme: &iced::Theme, status: button::Status| { + let state = forced.unwrap_or_else(|| Interaction::from_status(status)); + let colors = 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(content) + .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..5e880ba 100644 --- a/crates/app-iced/src/components/inputs/mod.rs +++ b/crates/app-iced/src/components/inputs/mod.rs @@ -1,35 +1,42 @@ -//! **输入类家族(桩)**:由后续 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; +#[cfg(test)] +mod probe; +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/probe.rs b/crates/app-iced/src/components/inputs/probe.rs new file mode 100644 index 0000000..8a1086e --- /dev/null +++ b/crates/app-iced/src/components/inputs/probe.rs @@ -0,0 +1,347 @@ +//! 渲染层几何断言(D30 / M68 的教训:**纯函数对 ≠ 图画对**)。 +//! +//! ## 为什么这个文件在 `inputs/` 而不是 `gallery/probe.rs` +//! +//! 像素工具 `gallery::scan` 是 `mod scan;`(**私有**于 `gallery`),我的 probe 拿不到它, +//! 而 `gallery/mod.rs` 不在本 mission 的 scope 里。所以这里自带一份最小的读图器 +//! (约 40 行:读 PNG / 取点 / 连通块 / 行覆盖),判据全在本文件——已作为 finding 报给 tower +//! (建议把 `gallery::scan` 提升为 `pub(crate)`,四个家族就能共用一份)。 +//! +//! ## 怎么跑(worktree 根目录) +//! +//! ```text +//! # ① 出图:副本工程只把 inputs 段提到首位(组件代码逐字节相同),见 target/m77/sync.py +//! python target/m77/sync.py && (cd /d/tmp/m77-shot && cargo build --no-default-features ...) +//! M77_PART=text ./target/debug/unlucky-launcher-iced.exe --software-renderer --screen=gallery \ +//! --screenshot target/m77/text-light.png --screenshot-theme=light +//! # ② 量图 +//! ULL_INPUTS_DIR=target/m77 cargo test -p unlucky-app-iced inputs::probe +//! ``` +//! +//! 没给 `ULL_INPUTS_DIR` 就**大声跳过**(不静默通过),与 `gallery::probe` 的口径一致。 +//! +//! ## 量什么(每条都对着一个「已经可能画错」的形态) +//! +//! | 断言 | 抓的错误 | +//! |---|---| +//! | 文本框高 **56**、宽 **320** | 高度被内容/内边距吃掉(不是 56 的 A 级值) | +//! | Filled 底部指示线 静息 **1.0** / 聚焦 **2.0** 墨量 | 把 2dp 画成 1dp(或反过来) | +//! | Outlined 描边 静息 **1.0** / 聚焦 **2.0** 墨量 | 同上 | +//! | 错误态:标签 / 线 / 辅助文本全 `Error` | 只把线染红、忘了标签与辅助文本 | +//! | 搜索条高 **56**、图标墨迹偏移 **20**(= 16 + viewBox 4) | 手抄 16/20 当图标边、或 pad 与 offset 少加一段 | +//! | docked 面板 宽 **360** / 头 **56** / 分隔线 **1.0 `Outline`** / 体 **240** | 用 `OutlineVariant`、或头部高度与 bar 不一致 | +//! | Slider 0%(竖条贴左)、50%(分界在中央)、100%(无未激活段) | 位移区间取错(0..W 而不是 handle/2..W−handle/2) | +//! | Slider 竖条 **4×44** | 画成圆点或撑满命中区 | +//! | 菜单项 pitch **48**、选中指示器内缩 **4**、容器高 **161** | 项高/内边距抄错 | +//! | 分组分隔线 **1.0** 墨量 | 分隔线变成 2px | + +use std::path::{Path, PathBuf}; + +use unlucky_design::color::{Role, SchemeFlavor}; +use unlucky_design::typography::Platform; + +use crate::theme::{Families, Ui}; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 颜色比对容差(抗锯齿/取整)。 +const TOLERANCE: u8 = 10; + +type Rgb = [u8; 3]; + +fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) +} + +fn rgb(color: iced::Color) -> Rgb { + [ + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8, + ] +} + +fn matches(pixel: Rgb, expected: Rgb) -> bool { + matches_within(pixel, expected, TOLERANCE) +} + +/// 指定容差的同色判断(量**阴影边**时要用更严的容差:阴影叠上去会落在 10 的容差内)。 +fn matches_within(pixel: Rgb, expected: Rgb, tolerance: u8) -> bool { + pixel + .iter() + .zip(expected) + .all(|(a, b)| a.abs_diff(b) <= tolerance) +} + +/// 一张图的最小访问器(读 PNG / 取点 / 连通块)。 +struct Shot { + width: usize, + height: usize, + pixels: Vec, +} + +/// 一张图里某颜色的连通块外接框(闭区间)。 +#[derive(Debug, Clone, Copy)] +struct BBox { + left: usize, + right: usize, + top: usize, + bottom: usize, +} + +impl BBox { + fn width(&self) -> usize { + self.right - self.left + 1 + } + + fn height(&self) -> usize { + self.bottom - self.top + 1 + } +} + +impl Shot { + fn load(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|error| format!("读不动 {path:?}:{error}"))?; + let image = image::load_from_memory(&bytes) + .map_err(|error| format!("解 PNG 失败:{error}"))? + .to_rgb8(); + let (width, height) = (image.width() as usize, image.height() as usize); + Ok(Self { + width, + height, + pixels: image.pixels().map(|p| p.0).collect(), + }) + } + + fn at(&self, x: usize, y: usize) -> Rgb { + self.pixels[y * self.width + x] + } + + fn bbox_of(&self, color: Rgb, min_area: usize) -> Vec { + let mut seen = vec![false; self.width * self.height]; + let mut boxes = Vec::new(); + for y in 0..self.height { + for x in 0..self.width { + if seen[y * self.width + x] || !matches(self.at(x, y), color) { + continue; + } + let mut stack = vec![(x, y)]; + seen[y * self.width + x] = true; + let mut bbox = BBox { + left: x, + right: x, + top: y, + bottom: y, + }; + while let Some((cx, cy)) = stack.pop() { + bbox.left = bbox.left.min(cx); + bbox.right = bbox.right.max(cx); + bbox.top = bbox.top.min(cy); + bbox.bottom = bbox.bottom.max(cy); + for (nx, ny) in [ + (cx + 1, cy), + (cx.saturating_sub(1), cy), + (cx, cy + 1), + (cx, cy.saturating_sub(1)), + ] { + if nx < self.width + && ny < self.height + && !seen[ny * self.width + nx] + && matches(self.at(nx, ny), color) + { + seen[ny * self.width + nx] = true; + stack.push((nx, ny)); + } + } + } + if bbox.width() * bbox.height() >= min_area { + boxes.push(bbox); + } + } + } + boxes + } + + fn count_in(&self, color: Rgb, x0: usize, x1: usize, y0: usize, y1: usize) -> usize { + let mut total = 0; + for y in y0..y1.min(self.height) { + for x in x0..x1.min(self.width) { + if matches(self.at(x, y), color) { + total += 1; + } + } + } + total + } +} + +/// 一行像素相对底色向 `line` 靠拢的比例(0..=1)——1dp 的线在各行上摊开时总和 ≈ 1.0。 +fn coverage(pixel: Rgb, surface: Rgb, line: Rgb) -> f32 { + let channel = |i: usize| { + let full = f32::from(line[i]) - f32::from(surface[i]); + if full.abs() < 1.0 { + return 0.0; + } + ((f32::from(pixel[i]) - f32::from(surface[i])) / full).clamp(0.0, 1.0) + }; + (channel(0) + channel(1) + channel(2)) / 3.0 +} + +/// 竖直方向上的**线墨量**:`y0..y1` 每行在 x 区间上的平均覆盖之和(1dp ⇒ ≈1.0)。 +fn line_ink( + shot: &Shot, + x0: usize, + x1: usize, + y0: usize, + y1: usize, + surface: Rgb, + line: Rgb, +) -> f32 { + let mut total = 0.0; + for y in y0..y1.min(shot.height) { + let mut sum = 0.0; + for x in x0..x1.min(shot.width) { + sum += coverage(shot.at(x, y), surface, line); + } + total += sum / (x1 - x0) as f32; + } + total +} + +/// 某一行里「两色之一」的最长连续段 `(start, end)`,允许 ≤`max_gap` 的空隙。 +/// +/// 用它而不是「本行最左/最右」:深色档的说明文字里有零星像素落在令牌色容差内, +/// 取 min/max 会把轨道量成从 x=24 起(实测)。 +fn longest_run(shot: &Shot, y: usize, a: Rgb, b: Rgb, max_gap: usize) -> Option<(usize, usize)> { + let mut best: Option<(usize, usize)> = None; + let mut start: Option = None; + let mut last: Option = None; + for x in 0..shot.width { + if matches(shot.at(x, y), a) || matches(shot.at(x, y), b) { + if start.is_none() { + start = Some(x); + } + last = Some(x); + } else if let (Some(from), Some(to)) = (start, last) { + if x - to > max_gap { + if best.is_none_or(|(p, q)| to - from > q - p) { + best = Some((from, to)); + } + start = None; + last = None; + } + } + } + if let (Some(from), Some(to)) = (start, last) { + if best.is_none_or(|(p, q)| to - from > q - p) { + best = Some((from, to)); + } + } + best +} + +/// `bbox` 范围内「整行至少 `min_pixels` 个同色像素」的行区间(闭区间)。 +/// +/// 菜单的阴影会把 bbox 外扩 ~5px,而阴影行绝不会有 100 个容器色像素——用它把阴影滤掉。 +fn row_extent(shot: &Shot, bbox: &BBox, color: Rgb, min_pixels: usize) -> (usize, usize) { + let mut first = None; + let mut last = None; + for y in bbox.top..=bbox.bottom { + let mut count = 0; + for x in bbox.left..=bbox.right { + // 容差收到 2:**阴影边**会落在默认容差 10 内,会把容器量高 ~5px + if matches_within(shot.at(x, y), color, 2) { + count += 1; + } + } + if count >= min_pixels { + first.get_or_insert(y); + last = Some(y); + } + } + ( + first.expect("行内没有足够的同色像素"), + last.expect("行内没有足够的同色像素"), + ) +} + +/// `pixel` 更接近 `fill` 还是 `surface`(含平局都算 `fill`)。 +fn nearer(pixel: Rgb, fill: Rgb, surface: Rgb) -> bool { + let dist = |a: Rgb, b: Rgb| -> u32 { + a.iter() + .zip(b) + .map(|(x, y)| (i32::from(*x) - i32::from(y)).unsigned_abs()) + .sum() + }; + dist(pixel, fill) <= dist(pixel, surface) +} + +/// 截图目录(`ULL_INPUTS_DIR`);没给就返回 `None`(调用方大声跳过)。 +fn shot_dir() -> Option { + let raw = std::env::var_os("ULL_INPUTS_DIR")?; + let path = PathBuf::from(raw); + if path.is_absolute() || path.is_dir() { + return Some(path); + } + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + Some(root.join(path)) +} + +/// 取一张图;缺图直接 panic(给了目录就说明这轮要认真量)。 +fn shot(dir: &Path, part: &str, flavor: SchemeFlavor) -> Shot { + let slug = match flavor { + SchemeFlavor::Light => "light", + SchemeFlavor::Dark => "dark", + }; + let path = dir.join(format!("{part}-{slug}.png")); + let shot = Shot::load(&path).unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + (shot.width, shot.height), + (1180, 760), + "验收图必须是 D30 的 1180×760 @100% DPI" + ); + shot +} + +/// 一个矩阵格在某列的竖直「实色高度」:从 `bbox.top-4` 往上/下走到不再接近 `fill` 为止。 +fn vertical_extent( + shot: &Shot, + bbox: &BBox, + fill: Rgb, + surface: Rgb, + column: usize, +) -> (usize, usize) { + let mut top = bbox.top; + while top > 0 && nearer(shot.at(column, top - 1), fill, surface) { + top -= 1; + } + let mut bottom = bbox.bottom; + while bottom + 1 < shot.height && nearer(shot.at(column, bottom + 1), fill, surface) { + bottom += 1; + } + (top, bottom) +} + +macro_rules! require_dir { + () => { + match shot_dir() { + Some(dir) => dir, + None => { + eprintln!( + "[inputs::probe] SKIPPED:未设置 ULL_INPUTS_DIR。这是**渲染层**断言,\ + 需要先按本文件模块文档出 8 张图(target/m77/*.png)。" + ); + return; + } + } + }; +} + +mod menu; +mod search; +mod slider; +mod text; diff --git a/crates/app-iced/src/components/inputs/probe/menu.rs b/crates/app-iced/src/components/inputs/probe/menu.rs new file mode 100644 index 0000000..b6ccdd0 --- /dev/null +++ b/crates/app-iced/src/components/inputs/probe/menu.rs @@ -0,0 +1,102 @@ +//! M77 probe · menu(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; + +/// 8.3 / 8.4 / 8.6:菜单容器高 161、项 pitch 48、选中指示器内缩 4、分组分隔线 1dp。 +#[test] +fn menu_container_items_and_divider() { + let dir = require_dir!(); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let shot = shot(&dir, "menu", flavor); + let ui = ui(flavor); + let container = rgb(ui.color(Role::SurfaceContainer)); + let selected = rgb(ui.color(Role::SecondaryContainer)); + let divider = rgb(ui.color(Role::OutlineVariant)); + + // 右侧那只 = 「展开态」菜单。容器色块可能被**分组分隔线 / 选中项**切成多块, + // 所以先把 x>290 的块并成一个外接框,再逐行量容器色(容差 2,滤掉阴影外扩的 ~5px)。 + let menus = shot.bbox_of(container, 2000); + let right: Vec = menus.iter().filter(|b| b.left > 290).copied().collect(); + assert!(!right.is_empty(), "找不到右侧的「展开态」菜单"); + let mut open = right[0]; + for b in right.iter().skip(1) { + open.left = open.left.min(b.left); + open.top = open.top.min(b.top); + open.right = open.right.max(b.right); + open.bottom = open.bottom.max(b.bottom); + } + let (top, bottom) = row_extent(&shot, &open, container, 1); + open.top = top; + open.bottom = bottom; + + // 布局高 **161** = 8 + 48×3 + 1 + 8;抗锯齿会吃掉上下各 1 行 ⇒ 实测 160 + assert!( + (159..=162).contains(&open.height()), + "8.3/8.4 菜单高应 161(= 8 + 48×3 + 1 + 8):实测 {}", + open.height() + ); + + // 选中项:SecondaryContainer,高 48、左右各内缩 4 + let selected_box = shot + .bbox_of(selected, 2000) + .into_iter() + .filter(|b| b.height() >= 44 && b.height() <= 50) + .max_by_key(|b| b.width()) + .expect("找不到选中项的 SecondaryContainer"); + assert!( + (46..=48).contains(&selected_box.height()), + "8.3 项高应 48:实测 {}", + selected_box.height() + ); + // 容器左右缘:在第一项那一行按严格同色扫(bbox 会被阴影外扩 ~2px,不能用) + let scan_y = open.top + 10; + let row: Vec = (open.left..=open.right) + .filter(|x| matches_within(shot.at(*x, scan_y), container, 2)) + .collect(); + let (menu_left, menu_right) = (*row.first().unwrap(), *row.last().unwrap()); + assert_eq!(selected_box.left - menu_left, 4, "8.6 选中指示器左内缩应 4"); + assert_eq!( + menu_right - selected_box.right, + 4, + "8.6 选中指示器右内缩应 4" + ); + + // 分组分隔线:1dp OutlineVariant 墨量 ≈1.0(位置在选中项与最后一项之间) + let ink = line_ink( + &shot, + open.left + 20, + open.right - 20, + selected_box.bottom + 1, + selected_box.bottom + 6, + container, + divider, + ); + assert!( + (0.6..=1.4).contains(&ink), + "8.x 分组分隔线应 1dp `OutlineVariant`:实测墨量 {ink:.2}" + ); + + // trailing 槽位必须靠到**行右内边距**上(视觉 r5 P1:早先它紧贴 label 右边)。 + // + // 位置公式:item 右缘 = menu_right − 4(选中指示器内缩);button 内容右缘 = item 右缘 − + // (pad_h − inset) = menu_right − 12;trailing 图标盒 24 的**墨迹**再内缩 k(`Check` 字形 + // 在 24 viewBox 里右边界 ≈ 5,与 leading 图标「可视边 12、墨迹 16」同一口径)。 + // 所以墨迹右缘 ≈ menu_right − 17。变异(去掉撑开的 Fill)会让它贴着 label ⇒ 差 ~120。 + let trailing_ink_color = rgb(ui.color(Role::OnSecondaryContainer)); + let mut trailing_right = 0usize; + for y in selected_box.top..=selected_box.bottom { + for x in selected_box.left..=selected_box.right { + if matches(shot.at(x, y), trailing_ink_color) { + trailing_right = trailing_right.max(x); + } + } + } + let trailing_offset = menu_right - trailing_right; + assert!( + (16..=18).contains(&trailing_offset), + "8.6/trailing:带 trailing 的项,其 trailing 墨迹右缘应在 item 右内边距上\ + (menu_right − 17,允许 ±1):实测 menu_right={menu_right}、墨迹右缘={trailing_right}\ + ⇒ 偏 {trailing_offset}(贴 label 时会是 ~120)" + ); + } +} diff --git a/crates/app-iced/src/components/inputs/probe/search.rs b/crates/app-iced/src/components/inputs/probe/search.rs new file mode 100644 index 0000000..40dc2c3 --- /dev/null +++ b/crates/app-iced/src/components/inputs/probe/search.rs @@ -0,0 +1,80 @@ +//! M77 probe · search(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; + +/// 20.1 / 20.4 / 20.6 / 20.8 / 20.9:条 56、图标墨迹偏移 20(= 16 + viewBox 4)、 +/// docked 面板 360 / 头 56 / 分隔线 1.0 `Outline` / 体 240。 +#[test] +fn search_bar_and_docked_panel_geometry() { + let dir = require_dir!(); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let shot = shot(&dir, "search", flavor); + let ui = ui(flavor); + let bar_color = rgb(ui.color(Role::SurfaceContainerHigh)); + let ink = rgb(ui.color(Role::OnSurface)); + + let bars = shot.bbox_of(bar_color, 2000); + let bar = bars + .iter() + .filter(|b| b.height() <= 60 && b.width() >= 360) + .min_by_key(|b| b.top) + .copied() + .expect("找不到搜索条(高 ≤60、宽 ≥360)"); + assert_eq!(bar.height(), 56, "20.1 搜索条高必须 56"); + assert!(bar.width() >= 360, "20.2 搜索条宽 ≥ 360"); + + // 20.4:图标墨迹左缘 = pad 12 + offset 4 + viewBox 内 ≈4 = 20(±1) + let inner = (bar.top + 12)..(bar.bottom - 12); + let mut left_ink = usize::MAX; + for y in inner.clone() { + for x in bar.left..bar.right { + if matches(shot.at(x, y), ink) { + left_ink = left_ink.min(x); + } + } + } + let offset = left_ink - bar.left; + assert!( + (19..=21).contains(&offset), + "20.4 前置图标墨迹偏移应 20(= 12 + 4 + viewBox 4):实测 {offset}" + ); + + // 20.6 / 20.9:docked 面板 360 宽,头 56 + 1px 分隔线 + 体 240 + // (头与体被那条线切成**两个同色块**,所以按 top 排序取前两块) + let mut parts = bars + .iter() + .filter(|b| b.width() == 360) + .copied() + .collect::>(); + parts.sort_by_key(|b| b.top); + assert_eq!(parts.len(), 2, "docked 面板 = 头部 + 结果区两块"); + let (header, body) = (parts[0], parts[1]); + assert!( + (55..=56).contains(&header.height()), + "20.6 面板头部高 56(与 bar 同高;顶部圆角会吃掉 1 行抗锯齿,实测 {})", + header.height() + ); + assert_eq!(body.height(), 240, "20.9 结果区最小高 240"); + assert!( + (2..=3).contains(&(body.top - header.bottom)), + "20.8 头与体之间正好 1dp(CPU 档摊成 2 行 ⇒ 隔 2~3 行):实测 {}", + body.top - header.bottom + ); + let panel = header; + + // 20.8:分隔线是 `Outline`(不是 OutlineVariant),1dp 墨量 ≈1.0 + let divider_ink = line_ink( + &shot, + panel.left + 40, + panel.right - 40, + panel.top + 50, + panel.top + 62, + bar_color, + rgb(ui.color(Role::Outline)), + ); + assert!( + (0.7..=1.3).contains(÷r_ink), + "20.8 头部分隔线应 1dp `Outline`:实测墨量 {divider_ink:.2}" + ); + } +} diff --git a/crates/app-iced/src/components/inputs/probe/slider.rs b/crates/app-iced/src/components/inputs/probe/slider.rs new file mode 100644 index 0000000..5473a9c --- /dev/null +++ b/crates/app-iced/src/components/inputs/probe/slider.rs @@ -0,0 +1,96 @@ +//! M77 probe · slider(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; + +/// 11.1~11.6:竖条 4×44、0% 贴左、50% 分界在中央、100% 无未激活段(thumb 永不越界)。 +#[test] +fn slider_thumb_positions_at_zero_half_and_full() { + let dir = require_dir!(); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let shot = shot(&dir, "slider", flavor); + let ui = ui(flavor); + let active = rgb(ui.color(Role::Primary)); + let inactive = rgb(ui.color(Role::SecondaryContainer)); + + // 三条「值行」= 宽 ≥400 的轨道(0% / 50% / 100%),按 y 排序 + let mut rows: Vec<(usize, usize, usize)> = Vec::new(); + for y in 0..shot.height { + if let Some((left, right)) = longest_run(&shot, y, active, inactive, 20) { + // 先按「跨度 ≥300」收行(把抗锯齿行也纳进来),band 建好后再按宽度筛。 + if right - left + 1 >= 300 { + rows.push((y, left, right)); + } + } + } + // 合并相邻的 y(同一条轨道占 16 行),并把每行的端点累积成 min/max—— + // 首行/末行都是抗锯齿行,只取它们会把轨道量窄 2~3px。 + let mut bands: Vec<(usize, usize, usize, usize)> = Vec::new(); + for (y, left, right) in rows { + match bands.last_mut() { + Some(last) if y - last.1 <= 3 => { + last.1 = y; + last.2 = last.2.min(left); + last.3 = last.3.max(right); + } + _ => bands.push((y, y, left, right)), + } + } + // 值行是 448 宽(`Space::X8 × 14`);五态行 5 条轨道会被并成 ~663 的带,按宽度筛掉 + let bands: Vec<(usize, usize, usize, usize)> = bands + .into_iter() + .filter(|(_, _, left, right)| (440..=470).contains(&(right - left + 1))) + .collect(); + assert_eq!( + bands.len(), + 4, + "值行应有 4 条(0% / 50% / 100% / 离散):实测 {}", + bands.len() + ); + + for (label, band) in ["0%", "50%", "100%"].iter().zip(bands.iter()) { + let (top, bottom, band_left, band_right) = *band; + let mid = (top + bottom) / 2; // 轨道中心行 + let (left, right) = (band_left, band_right); + let width = right - left + 1; + let handle_centre = left + 2 + (width - 4) / 2; // 位移区间 = handle/2 .. W-handle/2 + let boundary = (left..=right) + .find(|x| matches(shot.at(*x, mid), inactive)) + .unwrap_or(right + 1); + match *label { + "0%" => assert!( + boundary <= left + 4, + "11.x 0% 时未激活轨必须从竖条(4 宽)之后立刻开始:实测分界 {}", + boundary - left + ), + "50%" => assert!( + boundary.abs_diff(handle_centre) <= 2, + "11.x 50% 的轨道分界应在正中({handle_centre}):实测 {boundary}" + ), + _ => { + let tail = shot.count_in(inactive, left, right + 1, mid, mid + 1); + assert_eq!(tail, 0, "11.x 100% 时不该有未激活段:实测 {tail}px"); + } + } + + // 竖条必须落在轨道内(0% / 100% 的最狠情形) + let handles: Vec = shot + .bbox_of(active, 100) + .into_iter() + .filter(|b| b.width() == 4 && b.height() >= 40) + .collect(); + assert!(!handles.is_empty(), "11.2 找不到 4×44 的竖条"); + for handle in handles { + assert!( + handle.left >= left && handle.right <= right, + "11.4 竖条越界:{handle:?} 不在轨道 {left}..{right} 内" + ); + assert!( + (43..=44).contains(&handle.height()), + "11.2 竖条高必须 44(抗锯齿会吃掉 1 行,实测 {})", + handle.height() + ); + assert_eq!(handle.width(), 4, "11.2 竖条宽必须 4"); + } + } + } +} diff --git a/crates/app-iced/src/components/inputs/probe/text.rs b/crates/app-iced/src/components/inputs/probe/text.rs new file mode 100644 index 0000000..32eb2ca --- /dev/null +++ b/crates/app-iced/src/components/inputs/probe/text.rs @@ -0,0 +1,205 @@ +//! M77 probe · text(从 `probe.rs` 拆出:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; + +/// 9.1 / 9.2:Filled 高 56、宽 320;Filled 指示线 静息 1.0 / 聚焦 2.0 墨量。 +#[test] +fn text_field_height_width_and_indicator_thickness() { + let dir = require_dir!(); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let shot = shot(&dir, "text", flavor); + let ui = ui(flavor); + let surface = rgb(ui.color(Role::Surface)); + let fill = rgb(ui.color(Role::SurfaceContainerHighest)); + let line = rgb(ui.color(Role::OnSurfaceVariant)); + let primary = rgb(ui.color(Role::Primary)); + + let mut boxes = shot.bbox_of(fill, 400); + boxes.sort_by_key(|b| (b.top, b.left)); + let wide: Vec<&BBox> = boxes.iter().filter(|b| b.width() >= 280).collect(); + // 320 宽的有两个 Filled(空 / 有值);第三格是 Outlined 错误态——它没有容器色, + // 所以这里只要求 ≥2,错误态由 `outlined_stroke_and_error_paint` 单独验。 + assert!( + wide.len() >= 2, + "{flavor:?}: 至少要有 2 个 320 宽的 Filled 文本字段(实测 {})", + wide.len() + ); + + // 320 宽那三个 = 「空 / 有值 / 错误」三格;高度必须精确 56 + for bbox in wide.iter().take(2) { + assert!( + (280..=320).contains(&bbox.width()), + "9.2 最小宽 280:实测 {}(320 宽格)", + bbox.width() + ); + let column = bbox.right - 20; + let (top, bottom) = vertical_extent(&shot, bbox, fill, surface, column); + assert_eq!( + bottom - top + 1, + 56, + "9.1 高度必须精确 56(带浮动标签):实测 {flavor:?} @x={column}" + ); + } + + // 矩阵第一行(Filled 五态)=最靠上的 5 个 160 宽格:列序 = Interaction::ALL + // 五态里 disabled 的容器是 `OnSurface@12%`(另一种颜色),所以这里只有 4 块: + // enabled / hovered / pressed / focused —— 列序与 `Interaction::ALL` 一致。 + let row: Vec<&BBox> = boxes + .iter() + .filter(|b| (140..=170).contains(&b.width())) + .take(4) + .collect(); + assert_eq!(row.len(), 4, "{flavor:?}: Filled 矩阵应有 4 个同色格"); + let enabled = row[0]; + let focused = row[3]; + + // 静息指示线 1dp:墨量 ≈ 1.0 + let enabled_ink = line_ink( + &shot, + enabled.left + 8, + enabled.right - 8, + enabled.bottom, + enabled.bottom + 4, + fill, + line, + ); + assert!( + (0.6..=1.4).contains(&enabled_ink), + "9.4 静息指示线应 1dp:实测墨量 {enabled_ink:.2}" + ); + + // 聚焦指示线 2dp:墨量 ≈ 2.0(画成 1dp 会掉到 ~1.0) + let focused_ink = line_ink( + &shot, + focused.left + 8, + focused.right - 8, + focused.bottom, + focused.bottom + 5, + fill, + primary, + ); + assert!( + (1.5..=2.5).contains(&focused_ink), + "9.4 聚焦指示线应 2dp:实测墨量 {focused_ink:.2}(1dp 会掉到 ≈1.0)" + ); + assert!( + focused_ink > enabled_ink + 0.5, + "聚焦必须比静息粗一档({focused_ink:.2} vs {enabled_ink:.2})" + ); + } +} + +/// 9.4 / 9.7:Outlined 描边 静息 1dp / 聚焦 2dp;错误态线/标签/辅助文本全 `Error`。 +/// +/// 量法:**在某一列的竖线上扫**(不是找同色块)——深色档里 hover 描边的抗锯齿像素也会落进 +/// `Outline` 的容差里,按「最上面的同色块」取会取错行(实测 dark 取到了 hover 那一行)。 +#[test] +fn outlined_stroke_and_error_paint() { + let dir = require_dir!(); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let shot = shot(&dir, "text", flavor); + let ui = ui(flavor); + let surface = rgb(ui.color(Role::Surface)); + let fill = rgb(ui.color(Role::SurfaceContainerHighest)); + let outline = rgb(ui.color(Role::Outline)); + let primary = rgb(ui.color(Role::Primary)); + let error = rgb(ui.color(Role::Error)); + + // 矩阵第一列(enabled)的左缘与行距:列宽 160 + 间距 8 = 168 + let left = shot + .bbox_of(fill, 400) + .into_iter() + .filter(|b| (140..=170).contains(&b.width())) + .min_by_key(|b| (b.top, b.left)) + .expect("找不到 Filled 矩阵格") + .left; + let filled_bottom = shot + .bbox_of(fill, 400) + .into_iter() + .filter(|b| (140..=170).contains(&b.width())) + .min_by_key(|b| (b.top, b.left)) + .expect("找不到 Filled 矩阵格") + .bottom; + + let column = |index: usize| left + index * 168 + 120; + + // 静息(第 1 列)描边 1dp:在竖线上找第一处比底色更接近 `Outline` 的像素 + let rest_y = ((filled_bottom + 8)..shot.height) + .find(|y| nearer(shot.at(column(0), *y), outline, surface)) + .expect("竖线上找不到 Outlined 静息描边"); + let rest_ink = line_ink( + &shot, + column(0) - 40, + column(0) + 40, + rest_y.saturating_sub(2), + rest_y + 3, + surface, + outline, + ); + // 描边的墨量随**亚像素相位**在 1.0~1.67 之间浮动(同一 1dp:整行对齐 = 1.0, + // 落在半像素上 = 1 行满 + 1 行 0.67)。所以静息只做宽松下限,**宽度差异由比值判**。 + assert!( + (0.8..=1.9).contains(&rest_ink), + "9.4 Outlined 静息描边应 1dp(实测墨量 {rest_ink:.2};2dp 会到 2.0)" + ); + + // 聚焦(第 4 列)描边 2dp + let focus_y = ((filled_bottom + 8)..shot.height) + .find(|y| nearer(shot.at(column(3), *y), primary, surface)) + .expect("竖线上找不到 Outlined 聚焦描边"); + let focus_ink = line_ink( + &shot, + column(3) - 40, + column(3) + 40, + focus_y.saturating_sub(2), + focus_y + 4, + surface, + primary, + ); + assert!( + (1.8..=2.3).contains(&focus_ink), + "9.4 Outlined 聚焦描边应 2dp(实测墨量 {focus_ink:.2};1dp 会掉到 1.67)" + ); + let ratio = focus_ink / rest_ink; + assert!( + (1.6..=2.5).contains(&ratio), + "聚焦必须正好粗一倍(2dp/1dp):实测比值 {ratio:.2}({focus_ink:.2} vs {rest_ink:.2})" + ); + + // 9.7:错误格——描边 2dp + 标签(框内上带)+ 辅助文本(框下)全 `Error` + let error_box = shot + .bbox_of(error, 2000) + .into_iter() + .filter(|b| b.width() >= 280) + .max_by_key(|b| b.width()) + .expect("找不到错误态字段"); + assert!( + error_box.height() >= 50, + "错误格应是一整个 320 宽的字段(实测 {}×{})", + error_box.width(), + error_box.height() + ); + let label_ink = shot.count_in( + error, + error_box.left, + error_box.right + 1, + error_box.top + 2, + error_box.top + 22, + ); + let supporting_ink = shot.count_in( + error, + error_box.left, + error_box.right + 1, + error_box.bottom + 1, + error_box.bottom + 30, + ); + assert!( + label_ink > 20, + "9.7 错误标签没变成 Error(框内上带墨迹 {label_ink}px)" + ); + assert!( + supporting_ink > 20, + "9.7 错误辅助文本没变成 Error(框下墨迹 {supporting_ink}px)" + ); + } +} 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 + } +}