Files
Unlucky-Launcher/crates/app-iced/src/components/inputs/geometry.rs
T
AskaEth ddd234b3c8 feat(app-iced): M77 输入家族 —— TextField / Search / Slider / Menu(含 gallery 登记)
D39 五家族并行的「输入类」这一条线。四个组件全部对消息类型泛型(`M: Clone`,不碰
`app::Message`),状态一律走 `components::interaction`,精确尺寸走 `components::layout`,
零尺寸字面量(取不到的集中登记 G16~G20 在 `inputs/geometry.rs`)。

- TextField(12b §9):Filled / Outlined;56 高(无标签 40)、4 圆角、内边距 16/16、
  前置图标可视边 12(文本从 48 起)、指示线/描边 静息 1dp → 聚焦与错误 2dp、
  错误态线/标签/辅助文本全 Error、禁用 12%/38%(Filled 容器官方 4%,令牌只有 12% → D10)。
  标签常浮起(无上浮动画,D12);A1 无 Pressed token,press 按 hover(D11)。
- Search(15 §20):SearchBar(收起)+ SearchView(展开 docked)分开实现。bar 消费
  `ComponentMetrics::SEARCH_BAR`,图标可视边 = pad 12 + icon_offset 4 = 16(只做加法,
  不写 16/20),聚焦 1px Secondary;view 头部 56 + 1px **Outline** 分隔 + 结果区 240。
- Slider(12b §11):自绘 `advanced::Widget`——轨道 16 full、竖条 4×44(focus/pressed 收窄到 2)、
  激活 Primary / 未激活 SecondaryContainer、命中 48、离散停靠点 4(未激活轨在点两侧留 6 空隙)。
  位移区间取 handle/2..W−handle/2,所以 0%/100% 竖条不越界。
- Menu / DropdownMenuItem(12b §8):SurfaceContainer + L2 阴影(Elevation 令牌)、项 48、
  水平 12、上下 8、选中 SecondaryContainer(指示器内缩 4、圆角 8)、分组分隔线复用
  `components::divider`。圆角取令牌表 4(12b 说本项目 12 → D18)。
- gallery/inputs.rs:四组件状态矩阵登记(五态 × 两式 + 空/有值/错误、0%/50%/100%、菜单展开态),
  按 `part` 拆成四段(整段比 760 高的窗口还高)。

单文件 ≤400 行:四个组件的单测与 Slider 的纯几何分别拆到 `<name>/tests.rs` 与 `slider/track.rs`。
2026-11-02 07:45:36 +08:00

310 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 输入类家族的**内部几何**:能取令牌的一律取令牌,取不到的登记 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<IconKind>) -> 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);
}
}