diff --git a/crates/app-iced/src/components/gallery/navigation.rs b/crates/app-iced/src/components/gallery/navigation.rs index f917d79..1ca5830 100644 --- a/crates/app-iced/src/components/gallery/navigation.rs +++ b/crates/app-iced/src/components/gallery/navigation.rs @@ -1,22 +1,352 @@ -//! gallery 里的导航类小节(**桩**):后续 mission 只改本文件。 +//! gallery 里的导航类小节:四块状态矩阵 + 一块长文案块(**断言在 `navigation::probe`**)。 +//! +//! ## 块选择器 `ULL_NAV_PART` +//! +//! `ULL_GALLERY_SECTION=navigation` 只隔离家族;本家族自己就有四块矩阵 + 长文案块,竖排比 760 高, +//! 一屏只拍得到第一块。所以按契约 §9.2 的同一套规则再切一层(环境变量 + 默认兼容 + 未知值回落并打 +//! stderr):`ULL_NAV_PART=list|tabs|drawer|appbar|long|all`(默认 `all`)。 +//! +//! ```text +//! ULL_GALLERY_SECTION=navigation ULL_NAV_PART=list ./target/debug/unlucky-launcher-iced.exe //! --software-renderer --screen=gallery --screenshot target/m79/list-light.png --screenshot-theme=light +//! ULL_NAV_DIR=target/m79 cargo test -p unlucky-app-iced navigation::probe +//! ``` +//! +//! ## 矩阵形状 +//! +//! ```text +//! ListItem 行 = 一 / 两 / 三行(44 / 60 / 76) 列 = 选中 / 未选中 / 禁用(三列槽位完全相同) +//! Tabs 三行 Primary(选中第 1/2/3 个)+ 一行 Secondary + 一行五态 +//! Drawer rail 88 与 drawer 240 **同尺度并排**(D33),各 3 项、第 2 项 hover、第 3 项禁用 +//! AppBar 静息 / 滚动两态(56 高;滚动只换容器色 + L2) +//! long 长文案:页头 + 三行 ListItem + drawer(单行约束 D-M79-5 的取证) +//! ``` +//! +//! 取景板(`SurfaceContainerLow`)不是装饰:`Surface` 的页头/导航与页面底色同色,不垫一层板子, +//! 截图里量不到边界(probe 就是靠板量 88 / 240 的)。 -use iced::Element; +use iced::widget::{column, container, row, text}; +use iced::{Background, 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::navigation; use crate::theme::Ui; +use super::super::interaction::Interaction; +use super::super::navigation::{ + self, app_bar, list_item, navigation, tabs, AppBar, AppBarAction, ListItem, NavGroup, NavItem, + NavLayout, Navigation, Tab, Tabs, TabsVariant, +}; use super::{heading, DemoMessage}; +/// 块选择器的环境变量名(语义见模块文档)。 +pub const PART_ENV: &str = "ULL_NAV_PART"; + +/// 本次渲染哪一块(`all` = 四块都渲染,默认)。 +fn part() -> String { + let Ok(raw) = std::env::var(PART_ENV) else { + return "all".to_owned(); + }; + let name = raw.trim().to_ascii_lowercase(); + match name.as_str() { + "" | "all" | "list" | "tabs" | "drawer" | "appbar" | "long" => name, + _ => { + eprintln!( + "[gallery] {PART_ENV}={raw:?} 不是已知块(all|list|tabs|drawer|appbar|long),已回落 all" + ); + "all".to_owned() + } + } +} + +/// 演示消息(组件只认泛型,映射在 gallery 侧落地)。 +fn pressed() -> DemoMessage { + DemoMessage::Pressed("navigation") +} + +fn icon(name: &str) -> IconKind { + match name { + "download" => IconKind::Download, + "logs" => IconKind::Logs, + "settings" => IconKind::Settings, + _ => IconKind::Instances, + } +} + +/// 取景板:`SurfaceContainerLow` 底(`Surface` 的页头/导航要靠它才有边界)。 +fn plate<'a>( + ui: &'a Ui, + pad: f32, + content: impl Into>, +) -> Element<'a, DemoMessage> { + container(content) + .padding(pad) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::SurfaceContainerLow))), + ..container::Style::default() + }) + .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() +} + /// 导航类家族的小节。 pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { - iced::widget::column![ - heading( + let picked = part(); + let mut body = column![heading( + ui, + "navigation · list item / tabs / drawer / app bar", + "12b §4/§6/§16 + 15 §21:ListItem 44/60/76 · Tabs 48 + 指示条 3 · \ + Drawer = rail 88 ⇄ drawer 240(同一容器)· AppBar 56 + 滚动换色" + )] + .spacing(ui.space(Space::X8)); + + if picked == "all" || picked == "list" { + body = body.push(list_part(ui)); + } + if picked == "all" || picked == "tabs" { + body = body.push(tabs_part(ui)); + } + if picked == "all" || picked == "drawer" { + body = body.push(drawer_part(ui)); + } + if picked == "all" || picked == "appbar" { + body = body.push(app_bar_part(ui)); + } + if picked == "long" { + body = body.push(navigation::matrix::long_part(ui, pressed)); + } + body.into() +} + +/// 一个列表格(宽 = `nav_drawer_width` 240:够放下 icon + 两行文本,且与 drawer 药丸 216 可区分)。 +fn list_cell<'a>(ui: &'a Ui, item: ListItem<'a, DemoMessage>) -> Element<'a, DemoMessage> { + container(list_item(ui, item)) + .width(Length::Fixed(ui.metrics().nav_drawer_width)) + .into() +} + +/// ListItem:三行高 × {选中 / 未选中 / 禁用}。 +fn list_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { + let states = ["selected", "enabled", "disabled"]; + + let mut grid = column![].spacing(ui.space(Space::X4)); + let header = states + .iter() + .map(|name| { + container(note(ui, name)) + .width(Length::Fixed(ui.metrics().nav_drawer_width)) + .into() + }) + .collect::>>(); + grid = grid.push( + row![ + note(ui, "rows +16 each · 44 / 60 / 76"), + row(header).spacing(ui.space(Space::X2)) + ] + .spacing(ui.space(Space::X2)), + ); + + for (index, lines) in ["one line", "two lines", "three lines"].iter().enumerate() { + let mut cells = row![].spacing(ui.space(Space::X2)); + for state in states { + // 三列的**内容完全相同**(同样的槽位),只有态不同——否则「选中 trailing = Primary」 + // 这条结论没有可比对象(视觉 r1 P2-2 点名) + let mut item = ListItem::new(lines, pressed()) + .leading(icon("instances")) + .trailing(icon("settings")); + if index >= 1 { + item = item.supporting("3 versions · 1.21.4"); + } + if index >= 2 { + item = item.overline("RECENT"); + } + item = match state { + "selected" => item.selected(), + // 禁用是修饰器:这里只清消息,槽位全部保留 + "disabled" => item.disabled(), + _ => item.state(Interaction::Enabled), + }; + cells = cells.push(list_cell(ui, item)); + } + grid = grid.push(cells); + } + + column![ + note( ui, - "navigation · list item / tabs / drawer / app bar", - "12b §4/§6/§16 + 15 §21 —— pending(后续 mission 填充本文件)" + "ListItem · 选中 = SecondaryContainer(偏离 D-M79-1:A1 的 ListItem 不换底色)· \ + 禁用 = OnSurface@38%(A1 ListTokens)" ), - navigation::demo::(ui), + grid, ] - .spacing(ui.space(unlucky_design::spacing::Space::X2)) + .spacing(ui.space(Space::X2)) + .into() +} + +/// Tabs:两行 Primary(短 / 长文案各选中一次 → 指示条宽度必须不同)+ Secondary + 五态。 +fn tabs_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { + let primary_row = |selected: usize| { + let mut builder = Tabs::primary(); + for (index, label) in ["Logs", "Instance details", "Settings"].iter().enumerate() { + let mut tab = Tab::new(label, pressed()); + if index == 2 { + // 6.1:带图标的 Tabs 容器高 64(纯文字 48) + tab = tab.icon(icon("settings")); + } + if index == selected { + tab = tab.selected(); + } + builder = builder.tab(tab); + } + tabs(ui, builder) + }; + + let state_row = { + let mut builder = Tabs::primary(); + for (index, state) in [ + Interaction::Enabled, + Interaction::Hovered, + Interaction::Focused, + Interaction::Pressed, + Interaction::Disabled, + ] + .iter() + .enumerate() + { + let mut tab = Tab::new("Tab", pressed()).state(*state); + if index == 0 { + tab = tab.selected(); + } + builder = builder.tab(tab); + } + tabs(ui, builder) + }; + + let secondary = tabs( + ui, + Tabs::secondary() + .tab(Tab::new("Release", pressed()).selected()) + .tab(Tab::new("Snapshot", pressed())) + .tab(Tab::new("Legacy", pressed()).disabled()), + ); + + // 两式的名字进图例(同时把 `TabsVariant::ALL` 变成可点检的常量,而不是死代码) + let legend = TabsVariant::ALL + .iter() + .map(|variant| note(ui, variant.name())) + .collect::>(); + + column![ + note( + ui, + "Tabs · Primary 三行 = 选中第 1 / 2 / 3 个(指示条跟着走且宽度跟随该项);Secondary:1px SurfaceVariant" + ), + row(legend).spacing(ui.space(Space::X4)), + primary_row(0), + primary_row(1), + primary_row(2), + secondary, + note(ui, "Primary 五态(第 1 项选中;未选项 hover/focus/pressed 提到 OnSurface)"), + state_row, + ] + .spacing(ui.space(Space::X2)) + .into() +} + +/// 导航容器:rail 88 与 drawer 240 **同尺度并排**(D33 的可验收形式)。 +fn drawer_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { + // 取景板高 288(= Space::X8 × 9):装得下 drawer 的「两个分组 + 三项 + 两次标题」,否则末行被裁 + let plate_height = ui.space(Space::X8) * 9.0; + let mut panes = row![].spacing(ui.space(Space::X6)); + + for layout in NavLayout::ALL { + let builder = match layout { + NavLayout::Rail => Navigation::rail(), + NavLayout::Drawer => Navigation::drawer(), + }; + let pane = builder + .group( + NavGroup::titled("LIBRARY") + .item(NavItem::new("Instances", icon("instances"), pressed()).selected()) + .item( + NavItem::new("Download", icon("download"), pressed()) + .state(Interaction::Hovered), + ), + ) + .group( + NavGroup::titled("SYSTEM") + .item(NavItem::new("Settings", icon("settings"), pressed()).disabled()), + ); + let width = match layout { + NavLayout::Rail => ui.metrics().nav_rail_width, + NavLayout::Drawer => ui.metrics().nav_drawer_width, + }; + // 取景板比容器大一圈(pad 8):`Surface` 的导航与页面底色同色,板要露边才量得到 + let pad = ui.space(Space::X2); + panes = panes.push( + container(container(navigation(ui, pane)).height(Length::Fixed(plate_height))) + .width(Length::Fixed(width + pad * 2.0)) + .height(Length::Fixed(plate_height + pad * 2.0)) + .padding(pad) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::SurfaceContainerLow))), + ..container::Style::default() + }), + ); + } + + column![ + note( + ui, + "D33:同一个容器两个宽度 —— rail(药丸 56×32)|drawer(药丸 216×56,左右各缩 12); 第 2 项 hover、第 3 项禁用" + ), + panes, + row![ + note(ui, "左→右:"), + note(ui, NavLayout::Rail.name()), + note(ui, NavLayout::Drawer.name()), + ] + .spacing(ui.space(Space::X2)), + ] + .spacing(ui.space(Space::X2)) + .into() +} + +/// AppBar small:静息 / 滚动两态(56 高;滚动换容器色 + L2)。 +fn app_bar_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { + let bar = |scrolled: bool| { + app_bar( + ui, + AppBar::new("Instances") + .subtitle(if scrolled { + "scrolled · SurfaceContainer + L2" + } else { + "resting · Surface + L0" + }) + .leading(AppBarAction::inert(icon("instances"))) + .trailing(AppBarAction::new(icon("settings"), pressed())) + .scrolled(scrolled), + ) + }; + + column![ + note( + ui, + "AppBar small · 56 高(A1 64,偏离 D-M79-2)· 滚动只换容器色与高程,不改高度(A1 无 OnScroll…Height)" + ), + plate(ui, ui.space(Space::X2), column![bar(false), bar(true)].spacing(ui.space(Space::X2))), + ] + .spacing(ui.space(Space::X2)) .into() } diff --git a/crates/app-iced/src/components/navigation/app_bar.rs b/crates/app-iced/src/components/navigation/app_bar.rs new file mode 100644 index 0000000..0f6e5a6 --- /dev/null +++ b/crates/app-iced/src/components/navigation/app_bar.rs @@ -0,0 +1,353 @@ +//! AppBar(small)(`research/12b` §16):页头 56、标题 + 副标题、leading / trailing 槽位、滚动换色。 +//! +//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR) +//! +//! | # | 检查项 | 参考值 | 本项目 | +//! |---|---|---|---| +//! | 16.1 | 页头高 | `AppBarSmallTokens.ContainerHeight` = **64** | **56** = `LM.page_header_height`(登记偏离 −8) | +//! | 16.3 | 容器 | `AppBarTokens.ContainerColor = Surface` + L0;**滚动后** `OnScrollContainerColor = SurfaceContainer` + `OnScrollContainerElevation`(L2) | 同 | +//! | 16.4 | 字级 | `TitleFont = TitleLarge`(emphasized w500)· `SubtitleFont = LabelMedium` + `SubtitleColor = OnSurfaceVariant` | 同 | +//! | 16.5 | 图标 | `IconSize = 24`;leading `OnSurface`、trailing `OnSurfaceVariant` | 同 | +//! | 16.6 | 左右内边距 | `LeadingSpace = 4` / `TrailingSpace = 4`(+ IconButton 自带 40 宽 → 视觉起手 20) | 同 | +//! +//! ## 「滚动时的高度变化」——**规范上不存在**(不是我们没做) +//! +//! A1 的 `AppBarTokens` 全字段实读(`javap -p -c androidx.compose.material3.tokens.AppBarTokens`): +//! +//! ```text +//! AvatarSize 32 · ContainerColor Surface · ContainerElevation · ContainerShape CornerNone +//! IconButtonSpace · IconSize 24 · LeadingIconColor · LeadingSpace 4 +//! OnScrollContainerColor SurfaceContainer · OnScrollContainerElevation ← 滚动只改这两项 +//! SubtitleColor/SubtitleFont · TitleColor/TitleFont · TrailingIconColor · TrailingSpace 4 +//! ``` +//! +//! **整个 tokens 包里 `OnScroll*` 只有两个字段**(颜色与高程,见 `grep` 全量核对的报告), +//! 没有任何 `OnScroll…Height`;高度只在**尺寸档**(`AppBarSmallTokens.ContainerHeight = 64`)里, +//! 而那是 `small` / `medium` / `large` 三个**不同组件**的分档,不是滚动驱动的形变。 +//! 所以滚动时**高度不变**、只换容器色 + 升到 L2 阴影——这是规范,不是偏离。 +//! +//! 我们与规范的两处差异各登记一条: +//! +//! - **D-M79-2** 高度 56 而不是 64(项目骨架口径 `LM.page_header_height`,已有页面按 56 排版); +//! - 不做 `Medium` / `Large` 的**折叠(collapsing)**形态——那是另外两个组件(`AppBarMediumTokens` +//! / `AppBarLargeTokens`),本 mission 的 §16 只要求 small。 + +use iced::widget::text::{LineHeight, Wrapping}; +use iced::widget::{button, container, text}; +use iced::{ + Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow, Vector, +}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::Elevation; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +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::AppBarGeometry; + +/// 页头里的一个图标动作。 +#[derive(Debug, Clone)] +pub struct AppBarAction { + icon: IconKind, + on_press: Option, +} + +impl AppBarAction { + /// 一个可点的图标动作。 + pub fn new(icon: IconKind, on_press: M) -> Self { + Self { + icon, + on_press: Some(on_press), + } + } + + /// 一个装饰性图标(无消息)。 + pub fn inert(icon: IconKind) -> Self { + Self { + icon, + on_press: None, + } + } +} + +/// 一条 AppBar small。 +#[derive(Debug, Clone)] +pub struct AppBar<'a, M> { + title: &'a str, + subtitle: Option<&'a str>, + leading: Option>, + trailing: Vec>, + scrolled: bool, +} + +impl<'a, M> AppBar<'a, M> { + /// 标题(`TitleLarge`,emphasized)。 + pub fn new(title: &'a str) -> Self { + Self { + title, + subtitle: None, + leading: None, + trailing: Vec::new(), + scrolled: false, + } + } + + /// 副标题(`LabelMedium` + `OnSurfaceVariant`)。 + pub fn subtitle(mut self, subtitle: &'a str) -> Self { + self.subtitle = Some(subtitle); + self + } + + /// 前置动作(导航返回 / 汉堡;`OnSurface`)。 + pub fn leading(mut self, action: AppBarAction) -> Self { + self.leading = Some(action); + self + } + + /// 追加一个后置动作(`OnSurfaceVariant`)。 + pub fn trailing(mut self, action: AppBarAction) -> Self { + self.trailing.push(action); + self + } + + /// 滚动状态:`true` = 容器色 `SurfaceContainer` + L2 阴影(A1 `OnScroll*`)。 + pub fn scrolled(mut self, scrolled: bool) -> Self { + self.scrolled = scrolled; + self + } +} + +/// 一个图标动作按钮(40×40 命中区、图标 24 经 `layout::fixed`——Q9)。 +fn action<'a, M: Clone + 'a>( + ui: &'a Ui, + action: AppBarAction, + geometry: AppBarGeometry, + color: Color, +) -> Element<'a, M> { + let hovered = + interaction::overlay_color(ui, Interaction::Hovered, Role::OnSurface, Role::Surface); + let icon = layout::fixed( + geometry.icon, + geometry.icon, + icons::view::(action.icon, color), + ); + let hit = ui.metrics().control_height; + // 图标要落在 40×40 命中区的**正中**:`Container` 默认 `horizontal_alignment: Left` + // (`iced_widget-0.14.2/src/container.rs`),只给 `align_y` 会让 24px 图标贴左缘—— + // 视觉 r1 实测前导墨迹 `38..58`(应 `44..68`)。所以**两轴都显式给**(规则 Q21)。 + let button = button( + container(icon) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center), + ) + .width(Length::Fixed(hit)) + .height(Length::Fixed(hit)) + .padding(0) + .style(move |_theme, status| button::Style { + background: some_if_hovered(status, hovered), + text_color: color, + border: Border { + // IconButton 的圆角是 full:按命中区边长折算(`radius_dp` 不接受 Full) + radius: ui.radius(ShapeScale::Full, hit), + ..Border::default() + }, + shadow: Shadow::default(), + snap: true, + }); + match action.on_press { + Some(message) => button.on_press(message).into(), + None => button.into(), + } +} + +/// hover 时给背景(`None` 的其他态不叠状态层)。 +fn some_if_hovered(status: button::Status, hovered: Option) -> Option { + match status { + button::Status::Hovered => hovered.map(Background::Color), + _ => None, + } +} + +/// AppBar small(页头 56 / Surface / 滚动换色)。 +pub fn app_bar<'a, M: Clone + 'a>(ui: &'a Ui, bar: AppBar<'a, M>) -> Element<'a, M> { + let geometry = AppBarGeometry::of(ui); + + let title_style = ui.emphasized(TypeRole::TitleLarge); + let mut texts = iced::widget::Column::new().spacing(0); + texts = texts.push( + text(bar.title) + .size(title_style.size_px) + .line_height(LineHeight::Absolute(Pixels(title_style.line_height_px))) + // 单行:页头高固定 56,长标题折行会顶穿页头并与 trailing 动作重叠(偏离 D-M79-5) + .wrapping(Wrapping::None) + .font(ui.font_of(title_style)) + .color(ui.color(Role::OnSurface)), + ); + if let Some(subtitle) = bar.subtitle { + let style = ui.style(TypeRole::LabelMedium); + texts = texts.push( + text(subtitle) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .wrapping(Wrapping::None) + .font(ui.font_of(style)) + .color(ui.color(Role::OnSurfaceVariant)), + ); + } + + let mut row = iced::widget::Row::new() + .align_y(Alignment::Center) + .spacing(ui.space(unlucky_design::spacing::Space::X2)); + if let Some(leading) = bar.leading { + // 16.5:leading 图标 `OnSurface` + row = row.push(action(ui, leading, geometry, ui.color(Role::OnSurface))); + } + // 文本块吃掉剩余宽度并**裁切**:这一条同时解决两件事—— + // ① 标题与 trailing 之间必须有 Fill(M77 Q8 同款),② 单行超长标题切在动作按钮之前, + // 不会重叠(`clip(true)` 是 iced 里唯一能裁子元素越界的方式;没有 ellipsis,见 D-M79-5)。 + row = row.push(container(texts).width(Length::Fill).clip(true)); + for action_item in bar.trailing { + // 16.5:trailing 图标 `OnSurfaceVariant` + row = row.push(action( + ui, + action_item, + geometry, + ui.color(Role::OnSurfaceVariant), + )); + } + + let background = if bar.scrolled { + ui.color(Role::SurfaceContainer) + } else { + ui.color(Role::Surface) + }; + let elevation = scroll_elevation(bar.scrolled); + let shadow = if matches!(elevation, Elevation::Level0) { + Shadow::default() + } else { + let spec = elevation.shadow(); + Shadow { + color: Color { + a: spec.alpha, + ..ui.color(Role::Shadow) + }, + offset: Vector::new(0.0, spec.offset_y_px), + blur_radius: spec.blur_px, + } + }; + + container(row) + .width(Length::Fill) + .height(Length::Fixed(geometry.height)) + // 内容行(40 高的动作按钮 + 文本块)必须落在 56 页头的**垂直中线**上: + // `Container` 的 vertical_alignment 默认 Top,只给 `width` 会让整行贴顶 + // (视觉 r1 实测前导图标墨迹 y=204..223,居中应是 209..227)。 + .align_y(Alignment::Center) + .padding(Padding { + top: 0.0, + right: geometry.edge, + bottom: 0.0, + left: geometry.edge, + }) + .style(move |_theme| container::Style { + background: Some(Background::Color(background)), + border: Border { + // 16.3:ContainerShape = CornerNone + radius: ui.radius_dp(ShapeScale::None), + ..Border::default() + }, + shadow, + ..container::Style::default() + }) + .into() +} + +/// 滚动态的高程档(A1 `AppBarTokens.OnScrollContainerElevation`;静息 L0 = `ContainerElevation`)。 +fn scroll_elevation(scrolled: bool) -> Elevation { + if scrolled { + Elevation::Level2 + } else { + Elevation::Level0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use unlucky_design::color::SchemeFlavor; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Light, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) + } + + /// 16.1:页头高 = `LM.page_header_height`(56),并**钉死**它与 A1 的 64 不同。 + #[test] + fn height_is_pinned_to_the_page_header_token() { + let ui = ui(); + let geometry = AppBarGeometry::of(&ui); + assert_eq!(geometry.height, ui.metrics().page_header_height); + assert_eq!(geometry.height, 56.0); + assert_ne!( + geometry.height, 64.0, + "64 是 A1 AppBarSmallTokens.ContainerHeight;本项目按骨架令牌取 56(D-M79-2)" + ); + } + + /// 16.5 / 16.6:图标 24、左右内边距 4、头像 32。 + #[test] + fn icon_and_edges_come_from_tokens() { + let geometry = AppBarGeometry::of(&ui()); + assert_eq!(geometry.icon, 24.0); + assert_eq!(geometry.edge, 4.0); + assert_eq!(geometry.avatar, 32.0); + } + + /// 「滚动只换色 + 升 L2」:这条把 `OnScroll*` 的两个字段钉住(高度**不在**这里面)。 + #[test] + fn scroll_switches_color_and_elevation_only() { + let ui = ui(); + assert_eq!(scroll_elevation(false), Elevation::Level0); + assert_eq!(scroll_elevation(true), Elevation::Level2); + // 高度与滚动无关:同一个几何(这条是「滚动不改高度」的可执行声明) + assert_eq!( + AppBarGeometry::of(&ui).height, + ui.metrics().page_header_height, + "高度只由骨架令牌决定,没有 OnScroll…Height" + ); + } + + /// 树能建出来(leading + 副标题 + 两个 trailing + 滚动态)。 + #[test] + fn view_builds_a_tree() { + let ui = ui(); + let _: Element<'_, ()> = app_bar( + &ui, + AppBar::new("Instances") + .subtitle("4 instances") + .leading(AppBarAction::new(IconKind::Instances, ())) + .trailing(AppBarAction::new(IconKind::Settings, ())) + .trailing(AppBarAction::inert(IconKind::Logs)) + .scrolled(true), + ); + } +} diff --git a/crates/app-iced/src/components/navigation/drawer.rs b/crates/app-iced/src/components/navigation/drawer.rs new file mode 100644 index 0000000..2a8a690 --- /dev/null +++ b/crates/app-iced/src/components/navigation/drawer.rs @@ -0,0 +1,140 @@ +//! NavigationDrawer / NavigationRail(`research/15` §21 + 12b §5):**同一个容器的两个宽度**(D33)。 +//! +//! 本文件只装「容器」:宽度(88 ⇄ 240)、`CornerLargeEnd` 圆角、分组标题、组间分隔线。 +//! 一项长什么样(`NavItem` / `NavGroup` / 颜色求值 / 药丸排布)在 `drawer/item.rs`。 +//! +//! ## 参考值与 D33(详见 `geometry.rs` 的表) +//! +//! ```text +//! 折叠 = Rail 88 = LayoutMetrics::nav_rail_width 项高 52 = 药丸 32 + 4 + 标签 16 +//! 展开 = Drawer 240 = LayoutMetrics::nav_drawer_width 项高 56 = A1 ActiveIndicatorHeight(G24) +//! 药丸:rail 88 − 2×16 = 56(M68 的 56×32);drawer 240 − 2×12 = 216(= A1 的 360 − 2×12) +//! ``` +//! +//! 两者共用 [`navigation`] 一个入口、一套颜色角色、一套 full 圆角药丸;切换时圆角语言不变, +//! 一屏导航容器数恒为 1(§21.9)。**不是两套组件**——D33 的可验收形式就是「同一函数两个宽度」。 +//! +//! ## Q8 的现场 +//! +//! M68 的药丸写 56 宽后用 `center_x(Length::Fill)` 居中,容器尺寸被 Fill 覆盖成 88 宽(图上横贯全轨)。 +//! 这里药丸宽由 [`NavPaneGeometry::pill_width`] 给出,居中一律交给父级 `align_x`。 +//! +//! ## 分组标题(只 drawer 侧,§21.8) +//! +//! `TitleSmall` + `OnSurfaceVariant`,**不可点**;组与组之间 1px `OutlineVariant` +//! (复用 [`crate::app::components::divider`]),不用背景色块分区。 + +use iced::widget::container; +use iced::{Background, Border, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::typography::TypeRole; + +use crate::theme::Ui; + +use super::super::divider; +use super::geometry::{NavLayout, NavPaneGeometry}; + +mod item; +use item::{line, nav_row}; +pub use item::{NavGroup, NavItem}; + +/// 导航容器(D33:`layout` 决定 88 还是 240,其余全同)。 +#[derive(Debug, Clone)] +pub struct Navigation<'a, M> { + layout: NavLayout, + groups: Vec>, +} + +impl<'a, M> Navigation<'a, M> { + /// 折叠态:rail(88)。 + pub fn rail() -> Self { + Self { + layout: NavLayout::Rail, + groups: Vec::new(), + } + } + + /// 展开态:标准常驻 drawer(240)。 + pub fn drawer() -> Self { + Self { + layout: NavLayout::Drawer, + groups: Vec::new(), + } + } + + /// 追加分组(组间自动画 1px `OutlineVariant`)。 + pub fn group(mut self, group: NavGroup<'a, M>) -> Self { + self.groups.push(group); + self + } +} + +/// 导航容器(rail ⇄ drawer 的唯一入口)。 +pub fn navigation<'a, M: Clone + 'a>(ui: &'a Ui, nav: Navigation<'a, M>) -> Element<'a, M> { + let geometry = NavPaneGeometry::of(ui, nav.layout); + let gap = match nav.layout { + NavLayout::Rail => ui.metrics().nav_item_gap, + // drawer 的项是紧贴的(56 已经含了内部留白,§21.4) + NavLayout::Drawer => 0.0, + }; + + let mut body = iced::widget::Column::new().spacing(gap).width(Length::Fill); + for (index, group) in nav.groups.into_iter().enumerate() { + // 组间分隔线只属于 drawer:rail 88 宽里一条通栏线只会切碎药丸的节奏(§21.8 的分区是 drawer 语义) + if index > 0 && matches!(nav.layout, NavLayout::Drawer) { + body = body.push(container(divider::full::(ui)).padding(Padding { + top: ui.space(unlucky_design::spacing::Space::X2), + bottom: ui.space(unlucky_design::spacing::Space::X2), + left: 0.0, + right: 0.0, + })); + } + // 标题只在 drawer 侧出现(rail 88 宽放不下文字,§21.8 的分区是 drawer 语义) + if let (NavLayout::Drawer, Some(title)) = (nav.layout, group.title) { + body = body.push( + container(line::( + ui, + title, + TypeRole::TitleSmall, + ui.color(Role::OnSurfaceVariant), + )) + .width(Length::Fill) + .padding(Padding { + top: ui.space(unlucky_design::spacing::Space::X2), + right: geometry.pad_start, + bottom: ui.space(unlucky_design::spacing::Space::X1), + left: geometry.pill_inset + geometry.pad_start, + }), + ); + } + for item in group.items { + body = body.push(nav_row(ui, item, geometry)); + } + } + + container(body) + .width(Length::Fixed(geometry.width)) + .height(Length::Fill) + // 轨顶留白走 padding(不是 spacer 元素:会被 column 的 spacing 再加一档,M68 P3-5) + .padding(Padding { + top: ui.space(unlucky_design::spacing::Space::X6), + right: 0.0, + bottom: 0.0, + left: 0.0, + }) + .style(move |_theme| container::Style { + // §21.3 标准(常驻)= Surface + L0;形状 CornerLargeEnd(内侧两角 16) + background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))), + border: Border { + radius: geometry.radius, + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/navigation/drawer/item.rs b/crates/app-iced/src/components/navigation/drawer/item.rs new file mode 100644 index 0000000..4b4a667 --- /dev/null +++ b/crates/app-iced/src/components/navigation/drawer/item.rs @@ -0,0 +1,296 @@ +//! 导航项的公共类型与一行渲染(`drawer.rs` 的私有子模块:单文件 ≤400 行,AGENTS.md §3-1)。 +//! +//! 拆出去的是「一项长什么样」(`NavItem` / `NavGroup` + 颜色求值 + 一行布局), +//! `drawer.rs` 只留「容器长什么样」(宽度、圆角、分组标题、分隔线)。 + +use iced::widget::text::{LineHeight, Wrapping}; +use iced::widget::{button, container, text}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::typography::TypeRole; + +use crate::icons; +use crate::theme::Ui; + +use super::super::super::interaction::{self, Interaction}; +use super::super::super::layout; +use super::super::geometry::{NavLayout, NavPaneGeometry}; + +/// 一条导航项。 +#[derive(Debug, Clone)] +pub struct NavItem<'a, M> { + pub(super) label: &'a str, + pub(super) icon: IconKind, + pub(super) selected: bool, + pub(super) on_press: Option, + pub(super) state: Option, +} + +impl<'a, M> NavItem<'a, M> { + /// 一个可点的导航项(图标 + 文案)。 + pub fn new(label: &'a str, icon: IconKind, on_press: M) -> Self { + Self { + label, + icon, + selected: false, + on_press: Some(on_press), + state: None, + } + } + + /// 当前项(药丸 + `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 + } + + /// 转禁用:**只清消息**(修饰器;图标 / 选中态 / 槽位全部保留)。 + pub fn disabled(mut self) -> Self { + self.on_press = None; + self + } +} + +/// 一个分组(drawer 侧可带标题;rail 侧标题会被忽略——rail 只有图标)。 +#[derive(Debug, Clone)] +pub struct NavGroup<'a, M> { + pub(super) title: Option<&'a str>, + pub(super) items: Vec>, +} + +impl<'a, M> NavGroup<'a, M> { + /// 无标题分组。 + pub fn new() -> Self { + Self { + title: None, + items: Vec::new(), + } + } + + /// 带标题分组(§21.8:`TitleSmall` + `OnSurfaceVariant`,不可点)。 + pub fn titled(title: &'a str) -> Self { + Self { + title: Some(title), + items: Vec::new(), + } + } + + /// 追加一项。 + pub fn item(mut self, item: NavItem<'a, M>) -> Self { + self.items.push(item); + self + } +} + +impl<'a, M> Default for NavGroup<'a, M> { + fn default() -> Self { + Self::new() + } +} + +/// 一项的颜色(按态 + 选中求值)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct ItemPalette { + /// 药丸底色(`None` = 不画药丸 / 不叠状态层)。 + pub(super) pill: Option, + /// 图标色。 + pub(super) icon: Color, + /// 标签色。 + pub(super) label: Color, +} + +pub(super) fn palette( + ui: &Ui, + layout: NavLayout, + state: Interaction, + selected: bool, +) -> ItemPalette { + if state.is_disabled() { + let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false); + return ItemPalette { + pill: None, + icon: ink, + label: ink, + }; + } + + if selected { + let pill = interaction::overlay_color( + ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + ) + .unwrap_or_else(|| ui.color(Role::SecondaryContainer)); + return ItemPalette { + pill: Some(pill), + icon: ui.color(Role::OnSecondaryContainer), + // rail 的选中标签是 `Secondary`(12b 5.6);drawer 是 `OnSecondaryContainer`(§21.7) + label: match layout { + NavLayout::Rail => ui.color(Role::Secondary), + NavLayout::Drawer => ui.color(Role::OnSecondaryContainer), + }, + }; + } + + // 未选中:静息 `OnSurfaceVariant`,hover/focus/pressed 提到 `OnSurface` + let ink = match state { + Interaction::Enabled => ui.color(Role::OnSurfaceVariant), + _ => ui.color(Role::OnSurface), + }; + ItemPalette { + pill: interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface), + icon: ink, + label: ink, + } +} + +/// 一块文字(显式行高,让项高与行高对得上)。 +pub(super) fn line<'a, M: 'a>( + ui: &'a Ui, + value: &'a str, + role: TypeRole, + color: Color, +) -> Element<'a, M> { + let style = ui.style(role); + text(value) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + // 单行(项高固定 52/56):长分组名/实例名不许折行(偏离 D-M79-5) + .wrapping(Wrapping::None) + .font(ui.font_of(style)) + .color(color) + .into() +} + +/// 一项(rail:图标在上、标签在下;drawer:图标在左、标签在右)。 +pub(super) fn nav_row<'a, M: Clone + 'a>( + ui: &'a Ui, + item: NavItem<'a, M>, + geometry: NavPaneGeometry, +) -> Element<'a, M> { + let forced = item.state.or(if item.on_press.is_none() { + Some(Interaction::Disabled) + } else { + None + }); + let state = forced.unwrap_or(Interaction::Enabled); + let colors = palette(ui, geometry.layout, state, item.selected); + let radius = iced::border::Radius::new(geometry.pill_height / 2.0); + + let icon = layout::fixed( + geometry.icon, + geometry.icon, + icons::view::(item.icon, colors.icon), + ); + + // 药丸本体:宽高都来自几何(rail 56×32 / drawer 216×56),居中交给父级 align_x + let pill = move |content: Element<'a, M>| -> Element<'a, M> { + let fill = colors.pill; + container(content) + .width(Length::Fixed(geometry.pill_width)) + .height(Length::Fixed(geometry.pill_height)) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .style(move |_theme| container::Style { + background: fill.map(Background::Color), + border: Border { + radius, + ..Border::default() + }, + ..container::Style::default() + }) + .into() + }; + + let body: Element<'a, M> = match geometry.layout { + NavLayout::Rail => { + // rail:药丸(56×32,图标居中)+ 4 + 标签(LabelMedium) + let mut stack = iced::widget::Column::new() + .spacing(geometry.gap) + .width(Length::Fixed(geometry.width)) + .align_x(Alignment::Center); + stack = stack.push(pill(icon)); + // 标签要裁到轨宽:单行超长时切在 88 内,不会溢出到内容区(D-M79-5) + stack = stack.push( + container(line::( + ui, + item.label, + TypeRole::LabelMedium, + colors.label, + )) + .width(Length::Fixed(geometry.width)) + .align_x(Alignment::Center) + .clip(true), + ); + stack.into() + } + NavLayout::Drawer => { + // drawer:药丸就是一整行(216×56),内容从 pad_start 16 起、图标↔label 12 + let row = iced::widget::Row::new() + .align_y(Alignment::Center) + .spacing(geometry.gap) + .padding(Padding { + top: 0.0, + right: geometry.pad_end, + bottom: 0.0, + left: geometry.pad_start, + }); + // label 吃掉剩余宽度并裁切(单行 + 不压到药丸右内边距,D-M79-5) + pill(Element::from( + row.push(icon) + .push( + container(line::( + ui, + item.label, + TypeRole::LabelLarge, + colors.label, + )) + .width(Length::Fill) + .clip(true), + ) + .width(Length::Fill), + )) + } + }; + + let height = geometry.item_height; + let clickable = button(body) + .width(Length::Fill) + .height(Length::Fixed(height)) + .padding(0) + .style(move |_theme, _status| button::Style { + // 底色由药丸给(rail 的 hover 层也画在药丸上,与 drawer 同口径) + background: None, + text_color: colors.label, + border: Border::default(), + shadow: Shadow::default(), + snap: true, + }); + let element: Element<'a, M> = match item.on_press { + Some(message) => clickable.on_press(message).into(), + None => clickable.into(), + }; + + // drawer 的项外距 12(`ItemPadding`):药丸两侧由内缩给出,所以整行再包一层水平内边距 + match geometry.layout { + NavLayout::Drawer => container(element) + .width(Length::Fill) + .padding(Padding { + top: 0.0, + right: geometry.pill_inset, + bottom: 0.0, + left: geometry.pill_inset, + }) + .into(), + NavLayout::Rail => element, + } +} diff --git a/crates/app-iced/src/components/navigation/drawer/tests.rs b/crates/app-iced/src/components/navigation/drawer/tests.rs new file mode 100644 index 0000000..c160951 --- /dev/null +++ b/crates/app-iced/src/components/navigation/drawer/tests.rs @@ -0,0 +1,115 @@ +//! `drawer` 的单元测试(单文件 ≤400 行,AGENTS.md §3-1 拆出来)。 + +use super::super::super::interaction::{self, Interaction}; +use super::item::palette; +use super::*; + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::icons::IconKind; +use unlucky_design::typography::Platform; + +use crate::theme::Families; + +fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Light, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) +} + +/// §21.7:选中项图标/标签都是 `OnSecondaryContainer`,药丸 `SecondaryContainer`。 +#[test] +fn drawer_selected_uses_secondary_container() { + let ui = ui(); + let palette = palette(&ui, NavLayout::Drawer, Interaction::Enabled, true); + assert_eq!(palette.pill, Some(ui.color(Role::SecondaryContainer))); + assert_eq!(palette.icon, ui.color(Role::OnSecondaryContainer)); + assert_eq!(palette.label, ui.color(Role::OnSecondaryContainer)); +} + +/// 12b §5.6:**rail** 的选中标签是 `Secondary`(与 drawer 不同,别串线)。 +#[test] +fn rail_selected_label_is_secondary() { + let ui = ui(); + let rail = palette(&ui, NavLayout::Rail, Interaction::Enabled, true); + let drawer = palette(&ui, NavLayout::Drawer, Interaction::Enabled, true); + assert_eq!(rail.label, ui.color(Role::Secondary)); + assert_ne!(rail.label, drawer.label, "两条线的选中标签角色不同"); + // 但图标与药丸相同(同一套语言) + assert_eq!(rail.icon, drawer.icon); + assert_eq!(rail.pill, drawer.pill); +} + +/// 未选中:静息 `OnSurfaceVariant`;hover/focus/pressed 提到 `OnSurface` 且叠状态层。 +#[test] +fn inactive_lifts_to_on_surface() { + let ui = ui(); + let resting = palette(&ui, NavLayout::Drawer, Interaction::Enabled, false); + assert_eq!(resting.label, ui.color(Role::OnSurfaceVariant)); + assert_eq!(resting.pill, None); + for state in [ + Interaction::Hovered, + Interaction::Focused, + Interaction::Pressed, + ] { + let active = palette(&ui, NavLayout::Drawer, state, false); + assert_eq!(active.label, ui.color(Role::OnSurface), "{state:?}"); + assert_eq!(active.icon, ui.color(Role::OnSurface), "{state:?}"); + assert!(active.pill.is_some(), "{state:?} 必须叠状态层"); + } +} + +/// 禁用:`OnSurface`@38%,不画药丸,不叠状态层。 +#[test] +fn disabled_draws_no_pill() { + let ui = ui(); + let palette = palette(&ui, NavLayout::Drawer, Interaction::Disabled, true); + assert_eq!( + palette.icon, + interaction::disabled_content_over_role(&ui, Role::OnSurface, false) + ); + assert_eq!(palette.label, palette.icon); + assert_eq!(palette.pill, None); +} + +/// 禁用是**修饰器**:只清消息,图标 / 选中态全部保留。 +#[test] +fn disabled_only_clears_the_message() { + let item = NavItem::new("Settings", IconKind::Settings, ()) + .selected() + .disabled(); + assert!(item.on_press.is_none()); + assert!(item.selected); + assert_eq!(item.icon, IconKind::Settings); + assert_eq!(item.label, "Settings"); +} + +/// D33:两种宽度都能建出树(同一个 `navigation` 入口)。 +#[test] +fn both_layouts_build_a_tree() { + let ui = ui(); + for layout in NavLayout::ALL { + let base = match layout { + NavLayout::Rail => Navigation::rail(), + NavLayout::Drawer => Navigation::drawer(), + }; + let _: Element<'_, ()> = navigation( + &ui, + base.group( + NavGroup::titled("LIBRARY") + .item(NavItem::new("Instances", IconKind::Instances, ()).selected()) + .item(NavItem::new("Download", IconKind::Download, ())), + ) + .group(NavGroup::titled("SYSTEM").item(NavItem::new( + "Settings", + IconKind::Settings, + (), + ))), + ); + } +} diff --git a/crates/app-iced/src/components/navigation/geometry.rs b/crates/app-iced/src/components/navigation/geometry.rs new file mode 100644 index 0000000..1e1b88e --- /dev/null +++ b/crates/app-iced/src/components/navigation/geometry.rs @@ -0,0 +1,333 @@ +//! 导航类家族的**几何**:能取令牌的取令牌,取不到的在此登记 G 编号(不散落在组件里)。 +//! +//! ## 参考值 → 落点(`[源]` = A1,`material3-android:1.4.0`;本轮逐条用 `javap` 读本机 AAR 复核) +//! +//! ```text +//! javap -p -c androidx.compose.material3.tokens.ListTokens → 56/72/88、16/16、图标 24 +//! javap -p -c androidx.compose.material3.tokens.NavigationDrawerTokens +//! → ActiveIndicatorHeight 56、ActiveIndicatorWidth 336、ContainerWidth 360、CornerLargeEnd +//! javap -p -c androidx.compose.material3.NavigationDrawerItemDefaults +//! → ItemPadding = 12(bipush 12 → PaddingValues(12f, 0f)) +//! javap -p -c androidx.compose.material3.tokens.PrimaryNavigationTabTokens +//! → ContainerHeight 48、IconAndLabelTextContainerHeight 64、ActiveIndicatorHeight 3、图标 24 +//! javap -p -c androidx.compose.material3.tokens.SecondaryNavigationTabTokens → ContainerHeight 48 +//! javap -p -c androidx.compose.material3.tokens.AppBarSmallTokens → ContainerHeight 64 +//! ``` +//! +//! | # | 量 | 参考值(A1) | 本项目 | 来源 | 落点 | +//! |---|---|---|---|---|---| +//! | 4.1 | ListItem 一行高 | **56** | **44** = `LM.row_height`(登记偏离 −12) | `[源]` `ListItemOneLineContainerHeight` | [`ListItemGeometry::heights`] | +//! | 4.2 | ListItem 两/三行高 | **72 / 88** | **60 / 76**(每多一行 +16,与官方同 delta) | `[源]` | 同上 | +//! | 4.3 | ListItem 左右内边距 | **16 / 16** | 同 | `[源]` `ListItemLeading/TrailingSpace` | `pad_start` / `pad_end` | +//! | 4.4 | ListItem leading 图标 | **24** | 同 | `[源]` `ListItemLeadingIconSize` | `icon`(`IconSize::Toolbar`) | +//! | 4.4b | 图标 ↔ 文本间距 | 无 token | **16** | `[估]`(Compose 由 ListItem 内部排版决定) | `gap`(`Space::X4`) | +//! | 6.1 | Tabs 容器高 | **48**(带图标 **64**) | 同 | `[源]` | [`TABS_HEIGHT`](**G22**:`LM` 无此档) | +//! | 6.2 | 指示条 | 高 **3**、圆角 **3**、`Primary` | 同 | `[源]` `ActiveIndicatorHeight` + `RoundedCornerShape(3dp)` | [`TABS_INDICATOR`](**G23**) | +//! | 6.6 | Secondary 分隔线 | **1dp `SurfaceVariant`** | 同 | `[源]` `DividerHeight` / `DividerColor` | [`TabsGeometry::secondary_bar`] | +//! | 21.4 | drawer 项高 | **56**(`heightIn(min = ActiveIndicatorHeight)`) | **56**(`LM.nav_item_height` 是 44,登记偏离 +12) | `[源]` | [`DRAWER_ITEM_HEIGHT`](**G24**) | +//! | 21.5 | drawer 指示器 | 宽 = 容器宽 − **24**(360 → 336);高 = 项高;圆角 full | 240 − 24 = **216** | `[源]` `ItemPadding = 12` | [`NavPaneGeometry::pill_width`] | +//! | 21.6 | drawer 项内 | 项外 **12**;内 start **16** / end **24**;图标 24;图标↔label **12**;label `LabelLarge` | 同 | `[源]` `ItemPadding` + `navigation/15 §21.6` | [`ComponentMetrics::NAVIGATION_DRAWER`] | +//! | 21.2 | drawer 形状 | **`CornerLargeEnd`**(只有靠内容那侧两角 16,贴边侧直角) | 同(右两角 16) | `[源]` `ContainerShape` | [`NavPaneGeometry::radius`] | +//! | 16.1 | AppBar small 高 | **64** | **56** = `LM.page_header_height`(登记偏离 −8) | `[源]` `AppBarSmallTokens.ContainerHeight` | [`AppBarGeometry::height`] | +//! | 16.5/16.6 | AppBar 图标 / 左右内边距 | **24** / **4** | 同 | `[源]` `AppBarTokens.IconSize/LeadingSpace/TrailingSpace` | [`AppBarGeometry`] | +//! +//! **G 编号**(续 `research/15` §25 的 G13;G16~G20 已被 selection/inputs 用掉): +//! +//! - **G22** Tabs 容器高 48 / 带图标 64:`LayoutMetrics` 无此档,收在本文件的 [`TABS_HEIGHT`]; +//! - **G23** Tabs 指示条高 3 / 最小项宽 90:同上,收在本文件 [`TABS_INDICATOR`], +//! 而**最小项宽 90(`TabMinWidth`)本轮不施加**——iced 无 min-width,tab 按内容 hug, +//! 指示条因此严格等于「该 tab 的内容宽 + 2×16」,见 [`TabsGeometry::pad`] 的说明; +//! - **G24** drawer 项高 56:令牌 `LM.nav_item_height = 44` 是**列表行**的密集档,drawer 项按 A1 取 56。 +//! +//! ## 两个「同一个容器两个宽度」(D33) +//! +//! [`NavPaneGeometry::of`] 是**唯一**一处同时知道 88 与 240 的地方:宽 → 药丸宽、项高、内边距 +//! 全部由 `layout` 派生。Q8 的历史事故(药丸 56×32 被 `center_x(Fill)` 撑成 88×32)就出在 +//! 「药丸宽度不是从容器宽派生」——这里把派生写成函数,组件层拿不到手写数字。 + +use iced::border::Radius; + +use unlucky_design::component_map::{Component, ComponentMetrics}; +use unlucky_design::icons::IconSize; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; + +use crate::theme::Ui; + +/// Tabs 容器高(A1 `PrimaryNavigationTabTokens.ContainerHeight`,**G22**)。 +pub const TABS_HEIGHT: f32 = 48.0; +/// 带图标的 Tabs 容器高(A1 `IconAndLabelTextContainerHeight`,**G22**;本轮只做文字档)。 +pub const TABS_ICON_HEIGHT: f32 = 64.0; +/// Tabs 指示条高(A1 `PrimaryNavigationTabTokens.ActiveIndicatorHeight`,**G23**)。 +pub const TABS_INDICATOR: f32 = 3.0; +/// drawer 项高(A1 `NavigationDrawerItemDefaults` 的 `heightIn(min = ActiveIndicatorHeight)`,**G24**)。 +pub const DRAWER_ITEM_HEIGHT: f32 = 56.0; + +/// 导航容器的两种宽度(D33:**同一个容器**的折叠 / 展开,不是两套组件)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NavLayout { + /// 折叠:宽 **88**(`LayoutMetrics::nav_rail_width`),项内「药丸在上、标签在下」。 + Rail, + /// 展开:宽 **240**(`LayoutMetrics::nav_drawer_width`),项内「图标在左、标签在右」。 + Drawer, +} + +impl NavLayout { + /// 两种宽度(gallery)与测试用。 + pub const ALL: [NavLayout; 2] = [NavLayout::Rail, NavLayout::Drawer]; + + /// 稳定名字(截图文件名 / 图例用)。 + pub const fn name(self) -> &'static str { + match self { + NavLayout::Rail => "rail", + NavLayout::Drawer => "drawer", + } + } +} + +/// ListItem 的几何(`research/12b` §4)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ListItemGeometry { + /// 一 / 两 / 三行行高(44 / 60 / 76;A1 是 56 / 72 / 88,本项目登记偏离 −12)。 + pub heights: [f32; 3], + /// 内容起始侧内边距(16,A1 `ListItemLeadingSpace`)。 + pub pad_start: f32, + /// 内容结束侧内边距(16,A1 `ListItemTrailingSpace`)。 + pub pad_end: f32, + /// leading / trailing 图标边长(24,A1 `ListItemLeading/TrailingIconSize`)。 + pub icon: f32, + /// 图标 ↔ 文本间距(16,`[估]`)。 + pub gap: f32, +} + +impl ListItemGeometry { + /// 从令牌求值。 + pub fn of(ui: &Ui) -> Self { + let one = ui.metrics().row_height; + let step = ui.space(Space::X4); + Self { + // 每多一行 +16:与官方 56→72→88 的 delta 一致,差的只是基准值(−12)。 + heights: [one, one + step, one + step * 2.0], + pad_start: ui.space(Space::X4), + pad_end: ui.space(Space::X4), + icon: IconSize::Toolbar.px(), + gap: ui.space(Space::X4), + } + } + + /// `lines` 行的行高(1/2/3 之外按就近夹取,不 panic——调用方传入的是枚举值)。 + pub fn height(&self, lines: usize) -> f32 { + self.heights[lines.clamp(1, 3) - 1] + } + + /// `lines` 行的文本块高(行高 − 文本块 = 上下各一半的内边距)。 + pub fn text_block(&self, ui: &Ui, lines: usize) -> f32 { + let headline = ui.style(typography_headline()).line_height_px; + match lines.clamp(1, 3) { + // 一行:主文本(BodyLarge 24) + 1 => headline, + // 两行:主 + 辅助(BodyLarge 24 + BodyMedium 20) + 2 => headline + ui.style(typography_supporting()).line_height_px, + // 三行:overline(LabelSmall 16)+ 主 + 辅助 + _ => { + ui.style(typography_overline()).line_height_px + + headline + + ui.style(typography_supporting()).line_height_px + } + } + } + + /// 垂直内边距 = (行高 − 文本块) / 2(一行 44 → 10,两行 60 → 8,三行 76 → 8)。 + pub fn pad_y(&self, ui: &Ui, lines: usize) -> f32 { + (self.height(lines) - self.text_block(ui, lines)) / 2.0 + } +} + +/// ListItem 主文本字级(12b 4.5:`BodyLarge 16/24` `[源] ListTokens.ListItemLabelTextFont`)。 +pub const fn typography_headline() -> unlucky_design::typography::TypeRole { + unlucky_design::typography::TypeRole::BodyLarge +} + +/// ListItem 辅助文本字级(12b 4.5:`BodyMedium 14/20`)。 +pub const fn typography_supporting() -> unlucky_design::typography::TypeRole { + unlucky_design::typography::TypeRole::BodyMedium +} + +/// ListItem overline 字级(12b 4.5:`LabelSmall 11/16`)。 +pub const fn typography_overline() -> unlucky_design::typography::TypeRole { + unlucky_design::typography::TypeRole::LabelSmall +} + +/// Tabs 的几何(`research/12b` §6)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TabsGeometry { + /// 容器高(48,**G22**)。 + pub height: f32, + /// 图标边长(24,A1 `Primary/SecondaryNavigationTabTokens.IconSize`)。 + pub icon: f32, + /// 标签左右内边距(16,`[估]`:Compose `TabRow` 的 `HorizontalTextPadding`)。 + /// + /// 这条同时决定「指示条跟随选中项宽度」的**可测形态**:指示条宽 = 标签墨迹宽 + 2×16, + /// 所以两段文案长度不同的 tab,其指示条宽度必然不同(渲染层断言用它)。 + pub pad: f32, + /// Primary 指示条高(3,**G23**)。 + pub primary_bar: f32, + /// Secondary 分隔线厚度(1 = `LM.divider_thickness`)。 + pub secondary_bar: f32, +} + +impl TabsGeometry { + /// 从令牌求值。 + pub fn of(ui: &Ui) -> Self { + Self { + height: TABS_HEIGHT, + icon: IconSize::Toolbar.px(), + pad: ui.space(Space::X4), + primary_bar: TABS_INDICATOR, + secondary_bar: ui.metrics().divider_thickness, + } + } + + /// 指示条 / 分隔线高度:Primary 取 3px,Secondary 取 1px。 + pub fn bar(&self, primary: bool) -> f32 { + if primary { + self.primary_bar + } else { + self.secondary_bar + } + } + + /// Primary 指示条圆角:`RoundedCornerShape(3dp)` 落在 3px 高的条上,与 `Full`(=1.5 半径)渲染结果相同。 + /// + /// Secondary 的 1px 分隔线**没有圆角**:A1 是一条直线;早先给 0.5 圆角,相邻 tab 的分段之间 + /// 因此留下 1px 亮缝(视觉 r1 P2-2 实测 `25..101 | 103..191 | 193..265`)。 + pub fn indicator_radius(&self) -> Radius { + Radius::new(self.primary_bar / 2.0) + } +} + +/// 导航容器(rail ⇄ drawer)的几何(`research/15` §21 + D33)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NavPaneGeometry { + /// 哪种宽度(88 / 240)。 + pub layout: NavLayout, + /// 容器宽(88 / 240)。 + pub width: f32, + /// 项高(rail 52 = 药丸 32 + 4 + 标签 16;drawer 56,**G24**)。 + pub item_height: f32, + /// 选中药丸宽(rail 56 = 88 − 2×16;drawer 216 = 240 − 2×12)。 + pub pill_width: f32, + /// 选中药丸高(rail 32;drawer 56 = 项高)。 + pub pill_height: f32, + /// 药丸离容器左右各缩多少(rail 16;drawer 12 = `ItemPadding`)。 + pub pill_inset: f32, + /// 内容起始侧内边距(drawer 16;rail 由药丸居中决定)。 + pub pad_start: f32, + /// 内容结束侧内边距(drawer 24)。 + pub pad_end: f32, + /// 图标边长(24)。 + pub icon: f32, + /// 图标 ↔ 标签间距(rail 是纵向 4;drawer 是横向 12)。 + pub gap: f32, + /// 容器圆角(`CornerLargeEnd`:贴边侧直角、靠内容侧 16)。 + pub radius: Radius, +} + +impl NavPaneGeometry { + /// 从令牌求值:**88 与 240 只在这里分叉**,其余全部由宽度派生。 + pub fn of(ui: &Ui, layout: NavLayout) -> Self { + let metrics = ui.metrics(); + let item = drawer_metrics(); + // CornerLargeEnd:只有靠内容那一侧(右侧)两角 16,贴边侧直角。 + let corner = shape_radius(); + let radius = Radius { + top_left: 0.0, + top_right: corner, + bottom_right: corner, + bottom_left: 0.0, + }; + + match layout { + NavLayout::Rail => { + let width = metrics.nav_rail_width; + let pill_inset = ui.space(Space::X4); + let pill_height = metrics.control_height - ui.space(Space::X2); + Self { + layout, + width, + // 52 = 药丸 32 + 4 + 标签行高 16(与 `shell/nav.rs` 的项高同源) + item_height: pill_height + + ui.space(Space::X1) + + ui.style(unlucky_design::typography::TypeRole::LabelMedium) + .line_height_px, + pill_width: width - pill_inset * 2.0, + pill_height, + pill_inset, + pad_start: pill_inset, + pad_end: pill_inset, + icon: IconSize::Toolbar.px(), + gap: ui.space(Space::X1), + radius, + } + } + NavLayout::Drawer => { + let width = metrics.nav_drawer_width; + Self { + layout, + width, + item_height: DRAWER_ITEM_HEIGHT, + pill_width: item.indicator_width(width), + pill_height: DRAWER_ITEM_HEIGHT, + pill_inset: item.inset, + pad_start: item.pad_start, + pad_end: item.pad_end, + icon: item.icon, + gap: item.gap, + radius, + } + } + } + } +} + +/// 导航容器的组件级微度量(`ComponentMetrics::NAVIGATION_DRAWER`;缺令牌会直接炸,不静默回退)。 +fn drawer_metrics() -> ComponentMetrics { + Component::NavigationDrawer + .metrics() + .expect("NavigationDrawer 的 ComponentMetrics 是 G10 的落点") +} + +/// 容器圆角(`Component::NavigationDrawer.spec().shape = ShapeScale::Large` → 16)。 +fn shape_radius() -> f32 { + let shape = Component::NavigationDrawer.spec().shape; + debug_assert_eq!(shape, ShapeScale::Large, "ContainerShape=CornerLargeEnd"); + shape.dp() +} + +/// AppBar(small)的几何(`research/12b` §16)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AppBarGeometry { + /// 页头高(56 = `LM.page_header_height`;A1 是 64,登记偏离 −8)。 + pub height: f32, + /// 图标边长(24,A1 `AppBarTokens.IconSize`)。 + pub icon: f32, + /// 左右内边距(4,A1 `AppBarTokens.Leading/TrailingSpace`)。 + pub edge: f32, + /// 头像边长(32,A1 `AppBarTokens.AvatarSize`)。 + pub avatar: f32, +} + +impl AppBarGeometry { + /// 从令牌求值。 + pub fn of(ui: &Ui) -> Self { + Self { + height: ui.metrics().page_header_height, + icon: IconSize::Toolbar.px(), + edge: ui.space(Space::X1), + avatar: IconSize::Empty.px(), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/navigation/geometry/tests.rs b/crates/app-iced/src/components/navigation/geometry/tests.rs new file mode 100644 index 0000000..263832b --- /dev/null +++ b/crates/app-iced/src/components/navigation/geometry/tests.rs @@ -0,0 +1,131 @@ +//! `geometry` 的单元测试(split 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; + +use crate::theme::Families; + +fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Light, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) +} + +/// 4.1 / 4.2:三种行高 44 / 60 / 76(A1 56/72/88 的登记偏离 −12)。 +#[test] +fn list_item_heights_are_44_60_76() { + let geometry = ListItemGeometry::of(&ui()); + assert_eq!(geometry.heights, [44.0, 60.0, 76.0]); + for lines in 1..=3 { + assert_eq!(geometry.height(lines), geometry.heights[lines - 1]); + } + // 官方 delta 也是每行 +16 + assert_eq!(geometry.heights[1] - geometry.heights[0], 16.0); + assert_eq!(geometry.heights[2] - geometry.heights[1], 16.0); +} + +/// 行高必须**装得下**文本块(否则文字会被裁):三行文本块 16+24+20 = 60 ≤ 76。 +#[test] +fn list_item_rows_fit_their_text_block() { + let ui = ui(); + let geometry = ListItemGeometry::of(&ui); + for lines in 1..=3 { + let block = geometry.text_block(&ui, lines); + let pad = geometry.pad_y(&ui, lines); + assert!(pad >= 0.0, "{lines} 行的垂直内边距是负数(行高装不下文本)"); + assert!( + (block + pad * 2.0 - geometry.height(lines)).abs() < f32::EPSILON, + "{lines} 行:文本块 {block} + 2×{pad} ≠ 行高 {}", + geometry.height(lines) + ); + } + assert_eq!(geometry.text_block(&ui, 1), 24.0, "BodyLarge 24/24"); + assert_eq!(geometry.text_block(&ui, 2), 44.0, "24 + BodyMedium 20"); + assert_eq!(geometry.text_block(&ui, 3), 60.0, "LabelSmall 16 + 24 + 20"); + assert_eq!(geometry.pad_y(&ui, 1), 10.0, "(44 − 24) / 2"); + assert_eq!(geometry.pad_y(&ui, 3), 8.0, "(76 − 60) / 2"); +} + +/// 21.1 / 21.5 / 21.6:drawer 240、药丸 216(左右各缩 12)、项高 56、内边距 16/24。 +#[test] +fn drawer_pane_is_240_wide_with_a_216_pill() { + let geometry = NavPaneGeometry::of(&ui(), NavLayout::Drawer); + assert_eq!(geometry.width, 240.0, "D33 的展开宽度"); + assert_eq!(geometry.pill_inset, 12.0, "ItemPadding"); + assert_eq!( + geometry.pill_width, 216.0, + "240 − 2×12(A1: 360 − 2×12 = 336)" + ); + assert_eq!(geometry.item_height, 56.0, "G24:A1 ActiveIndicatorHeight"); + assert_eq!(geometry.pill_height, 56.0, "指示器高 = 项高"); + assert_eq!((geometry.pad_start, geometry.pad_end), (16.0, 24.0)); + assert_eq!(geometry.gap, 12.0, "图标 ↔ label"); + assert_eq!(geometry.icon, 24.0); + // 药丸 + 两侧内缩 = 容器宽(这条是「药丸从容器宽派生」的等式,Q8 的回归点) + assert!((geometry.pill_width + geometry.pill_inset * 2.0 - geometry.width).abs() < 0.01); +} + +/// D33:rail 与 drawer 是**同一个容器**的两个宽度(88 ⇄ 240),不是两套组件。 +#[test] +fn rail_and_drawer_share_one_container() { + let ui = ui(); + let rail = NavPaneGeometry::of(&ui, NavLayout::Rail); + let drawer = NavPaneGeometry::of(&ui, NavLayout::Drawer); + + assert_eq!(rail.width, 88.0); + assert_eq!(drawer.width, 240.0); + assert!(rail.width < drawer.width); + + // 同一个「药丸 = 容器宽 − 2×内缩」公式 + for pane in [rail, drawer] { + assert!( + (pane.pill_width + pane.pill_inset * 2.0 - pane.width).abs() < 0.01, + "{:?} 的药丸宽不是从容器宽派生的", + pane.layout + ); + // 圆角语言不变:都是 CornerLargeEnd(外侧直角、内侧 16) + assert_eq!((pane.radius.top_left, pane.radius.bottom_left), (0.0, 0.0)); + assert_eq!( + (pane.radius.top_right, pane.radius.bottom_right), + (16.0, 16.0) + ); + assert_eq!(pane.icon, 24.0); + } + + // rail 的药丸是 56×32(M68 已修正的结论),不是 drawer 的 216×56 + assert_eq!((rail.pill_width, rail.pill_height), (56.0, 32.0)); + assert_eq!((drawer.pill_width, drawer.pill_height), (216.0, 56.0)); +} + +/// 6.1 / 6.2 / 6.6:Tabs 48 高、指示条 3、Secondary 线 1。 +#[test] +fn tabs_geometry_matches_the_reference() { + let ui = ui(); + let geometry = TabsGeometry::of(&ui); + assert_eq!(geometry.height, 48.0, "G22"); + assert_eq!(geometry.icon, 24.0); + assert_eq!(geometry.pad, 16.0); + assert_eq!(geometry.bar(true), 3.0, "ActiveIndicatorHeight"); + assert_eq!(geometry.bar(false), 1.0, "DividerHeight"); + // RoundedCornerShape(3dp) 落在 3px 高的条上 = 半径 1.5(与 Full 同渲染) + assert_eq!(geometry.indicator_radius().top_left, 1.5); +} + +/// 16.1 / 16.5:AppBar 56(LM)、图标 24、左右 4、头像 32。 +#[test] +fn app_bar_geometry_matches_the_skeleton() { + let geometry = AppBarGeometry::of(&ui()); + assert_eq!( + geometry.height, 56.0, + "LM.page_header_height(A1 64,登记偏离)" + ); + assert_eq!((geometry.icon, geometry.edge), (24.0, 4.0)); + assert_eq!(geometry.avatar, 32.0); +} diff --git a/crates/app-iced/src/components/navigation/list_item.rs b/crates/app-iced/src/components/navigation/list_item.rs new file mode 100644 index 0000000..3f44d0a --- /dev/null +++ b/crates/app-iced/src/components/navigation/list_item.rs @@ -0,0 +1,306 @@ +//! ListItem(`research/12b` §4):一行 / 两行 / 三行,leading / trailing 槽位,选中态,五态状态层。 +//! +//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR 的 `ListTokens` / `ListItemColors`) +//! +//! | # | 检查项 | 参考值 | 落点 | +//! |---|---|---|---| +//! | 4.1 | 一行高 | A1 **56** → 本项目 **44**(`LM.row_height`,登记偏离 −12) | [`ListItemGeometry::heights`] | +//! | 4.2 | 两 / 三行高 | A1 **72 / 88** → 本项目 **60 / 76**(每多一行 +16,与官方同 delta) | 同上 | +//! | 4.3 | 左右内边距 | **16 / 16** | `pad_start` / `pad_end` | +//! | 4.4 | leading | 图标 **24** `OnSurfaceVariant` | `IconSize::Toolbar` | +//! | 4.5 | 字级 | 主 `BodyLarge 16/24`、辅助 `BodyMedium 14/20`、overline `LabelSmall 11/16` | `TypeRole` | +//! | 4.7 | 状态 | hover/focus/pressed **文本与图标色不变**,只叠状态层(8/10/10%) | [`interaction`] | +//! | 4.8 | 选中 trailing | 图标 → **`Primary`** | `Role::Primary` | +//! +//! ## 禁用是**修饰器**,不是构造函数(照 `selection/chip.rs` 复审 P2-1 的结论) +//! +//! [`ListItem::disabled`] **只清消息**:图标、选中态、状态层全部保留。早期 chip 的 +//! `Chip::disabled(label)` 构造函数把图标和选中态一起丢了,图上是「另一行」,不是「这一行禁用」。 +//! +//! ## 禁用内容角色是 **`OnSurface`**,不是默认的 `OnSurfaceVariant` +//! +//! A1 实读(`javap -p -c androidx.compose.material3.tokens.ListTokens`): +//! +//! ```text +//! ListItemDisabledLabelTextColor = OnSurface (+ ListItemDisabledLabelTextOpacity = 0.38f) +//! ListItemDisabledLeadingIconColor = OnSurface (+ 0.38f) +//! ListItemDisabledTrailingIconColor = OnSurface (+ 0.38f) +//! ``` +//! +//! 所以这里必须走 `interaction::disabled_content_over_role(ui, OnSurface, false)`; +//! 用默认的 `disabled_colors()`(写死 `OnSurfaceVariant`)会偏浅一档。 +//! +//! ## 选中态:本项目口径(**偏离登记 D-M79-1**) +//! +//! A1 的 `ListItemColors` **没有** selected 容器色(实读字段:containerColor / headline / leadingIcon / +//! overline / supporting / trailingIcon + 三个 disabled),选中只把 trailing 图标换成 `Primary`。 +//! mission 要求「选中态(SecondaryContainer)」,且本项目的菜单项(M77)与 drawer 项都用 +//! `SecondaryContainer` 表示「当前项」——三处保持同一语言,故 ListItem 也填 `SecondaryContainer` +//! + `OnSecondaryContainer`。**这是实质偏离**:A1 的 ListItem 选中行不换底色。 + +use iced::widget::text::{LineHeight, Wrapping}; +use iced::widget::{button, container, text}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; + +use crate::icons; +use crate::theme::Ui; + +use super::super::interaction::{self, Interaction}; +use super::super::layout; +use super::geometry::{ + typography_headline, typography_overline, typography_supporting, ListItemGeometry, +}; + +/// 一个列表项(一行 / 两行 / 三行由槽位决定)。 +#[derive(Debug, Clone)] +pub struct ListItem<'a, M> { + overline: Option<&'a str>, + headline: &'a str, + supporting: Option<&'a str>, + leading: Option, + trailing: Option, + on_press: Option, + selected: bool, + state: Option, +} + +impl<'a, M> ListItem<'a, M> { + /// 一行项(主文本 + 可点消息)。 + pub fn new(headline: &'a str, on_press: M) -> Self { + Self { + overline: None, + headline, + supporting: None, + leading: None, + trailing: None, + on_press: Some(on_press), + selected: false, + state: None, + } + } + + /// 加一行辅助文本 → **两行**项(4.5:辅助 `BodyMedium`)。 + pub fn supporting(mut self, supporting: &'a str) -> Self { + self.supporting = Some(supporting); + self + } + + /// 加上方 overline → **三行**项(4.5:overline `LabelSmall`,`OnSurfaceVariant`)。 + pub fn overline(mut self, overline: &'a str) -> Self { + self.overline = Some(overline); + self + } + + /// 前置槽位(图标 24,`OnSurfaceVariant`)。 + pub fn leading(mut self, kind: IconKind) -> Self { + self.leading = Some(kind); + self + } + + /// 后置槽位(未选中 `OnSurface`,选中 `Primary`,见 4.8)。 + pub fn trailing(mut self, kind: IconKind) -> Self { + self.trailing = Some(kind); + self + } + + /// 选中态(`SecondaryContainer` + `OnSecondaryContainer`;偏离 D-M79-1 见模块文档)。 + pub fn selected(mut self) -> Self { + self.selected = true; + self + } + + /// 强制视觉态(gallery 状态矩阵取证;不动状态机)。 + pub fn state(mut self, state: Interaction) -> Self { + self.state = Some(state); + self + } + + /// 转禁用:**只清消息**,槽位与选中态一律保留。 + pub fn disabled(mut self) -> Self { + self.on_press = None; + self + } + + /// 行数(1 + 有 overline + 有 supporting)。 + pub fn lines(&self) -> usize { + usize::from(self.overline.is_some()) + 1 + usize::from(self.supporting.is_some()) + } +} + +/// 一项的颜色(按态 + 选中求值;禁用按 A1 的 `OnSurface`@38%)。 +#[derive(Debug, Clone, Copy, PartialEq)] +struct ItemPalette { + fill: Option, + headline: Color, + supporting: Color, + leading: Color, + trailing: Color, +} + +fn palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette { + if state.is_disabled() { + // A1:ListItemDisabled*Color = OnSurface × 0.38(无容器时叠在 surface 上) + let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false); + return ItemPalette { + fill: None, + headline: ink, + supporting: ink, + leading: ink, + trailing: ink, + }; + } + + if selected { + let fill = interaction::overlay_color( + ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + ) + .unwrap_or_else(|| ui.color(Role::SecondaryContainer)); + return ItemPalette { + fill: Some(fill), + headline: ui.color(Role::OnSecondaryContainer), + supporting: ui.color(Role::OnSecondaryContainer), + leading: ui.color(Role::OnSecondaryContainer), + // 4.8:选中时 trailing 图标是 `Primary`(A1 ListItemSelectedTrailingIconColor) + trailing: ui.color(Role::Primary), + }; + } + + ItemPalette { + // 状态层叠在**行自己的底**(Surface)上,得到不透明结果(D27) + fill: interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface), + headline: ui.color(Role::OnSurface), + supporting: ui.color(Role::OnSurfaceVariant), + leading: ui.color(Role::OnSurfaceVariant), + trailing: ui.color(Role::OnSurface), + } +} + +/// 一行文本(显式行高:三行块的 16+24+20 必须能精确等于 60,靠的就是它)。 +fn line<'a, M: 'a>( + ui: &'a Ui, + value: &'a str, + role: unlucky_design::typography::TypeRole, + color: Color, +) -> Element<'a, M> { + let style = ui.style(role); + text(value) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + // **单行**:行高是固定值(44/60/76),`Text` 默认 `Wrapping::Word` 会把长实例名 + // 折成两行、把三行块顶穿。官方要的是单行 ellipsis,iced 没有 ellipsis(D19 先例) + // → 单行 + 超长由外层 `clip(true)` 直接切(偏离 D-M79-5)。 + .wrapping(Wrapping::None) + .font(ui.font_of(style)) + .color(color) + .into() +} + +/// 列表项(`research/12b` §4)。 +pub fn list_item<'a, M: Clone + 'a>(ui: &'a Ui, item: ListItem<'a, M>) -> Element<'a, M> { + let geometry = ListItemGeometry::of(ui); + let lines = item.lines(); + let height = geometry.height(lines); + // 禁用 = 「没有消息」;`state` 是取证强制值,优先级更高(矩阵里画 hover 的禁用项也成立) + let forced = item.state.or(if item.on_press.is_none() { + Some(Interaction::Disabled) + } else { + None + }); + let colors = palette(ui, forced.unwrap_or(Interaction::Enabled), item.selected); + + let mut body = iced::widget::Column::new().spacing(0); + if let Some(overline) = item.overline { + body = body.push(line::( + ui, + overline, + typography_overline(), + colors.supporting, + )); + } + body = body.push(line::( + ui, + item.headline, + typography_headline(), + colors.headline, + )); + if let Some(supporting) = item.supporting { + body = body.push(line::( + ui, + supporting, + typography_supporting(), + colors.supporting, + )); + } + + let mut content = iced::widget::Row::new() + .align_y(Alignment::Center) + .spacing(geometry.gap); + if let Some(kind) = item.leading { + // Q9:图标必须经固定尺寸容器(否则会被按钮的 Fill 约束撑满) + content = content.push(layout::fixed( + geometry.icon, + geometry.icon, + icons::view::(kind, colors.leading), + )); + } + // 文本块吃掉剩余宽度并**裁切**:单行超长时切在行内边距上,不会压到 trailing + content = content.push(container(body).width(Length::Fill).clip(true)); + if let Some(kind) = item.trailing { + content = content.push(layout::fixed( + geometry.icon, + geometry.icon, + icons::view::(kind, colors.trailing), + )); + } + + let pad_y = geometry.pad_y(ui, lines); + // 行必须撑满 item 宽度(Fill 空间要有宽度可撑),垂直靠 align_y 居中(`button` 默认贴左上) + let body = container(content) + .width(Length::Fill) + .height(Length::Fill) + .align_y(Alignment::Center); + + let selected = item.selected; + let style = move |_theme: &iced::Theme, status: button::Status| { + let state = forced.unwrap_or_else(|| Interaction::from_status(status)); + let colors = palette(ui, state, selected); + button::Style { + background: colors.fill.map(Background::Color), + text_color: colors.headline, + // 4.x:ListItem 的形状是 `CornerNone`(直角),没有圆角档 + border: Border { + radius: ui.radius_dp(ShapeScale::None), + ..Border::default() + }, + shadow: Shadow::default(), + snap: true, + } + }; + + let button = button(body) + .width(Length::Fill) + .height(Length::Fixed(height)) + .padding(Padding { + top: pad_y, + right: geometry.pad_end, + bottom: pad_y, + left: geometry.pad_start, + }) + .style(style); + + let element: Element<'a, M> = match item.on_press { + Some(message) => button.on_press(message).into(), + None => button.into(), + }; + element +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/navigation/list_item/tests.rs b/crates/app-iced/src/components/navigation/list_item/tests.rs new file mode 100644 index 0000000..b1be068 --- /dev/null +++ b/crates/app-iced/src/components/navigation/list_item/tests.rs @@ -0,0 +1,127 @@ +//! `list_item` 的单元测试(split 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; + +use crate::theme::Families; + +fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Light, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) +} + +/// 槽位决定行数:1 / 2 / 3(三行是 overline + headline + supporting)。 +#[test] +fn line_count_follows_the_slots() { + let one = ListItem::<()>::new("Instances", ()); + assert_eq!(one.lines(), 1); + let two = ListItem::<()>::new("Instances", ()).supporting("3 versions"); + assert_eq!(two.lines(), 2); + let three = ListItem::<()>::new("Instances", ()) + .overline("RECENT") + .supporting("3 versions"); + assert_eq!(three.lines(), 3); + // overline 单独存在也是两行(overline + headline) + let overline_only = ListItem::<()>::new("Instances", ()).overline("RECENT"); + assert_eq!(overline_only.lines(), 2); +} + +/// **禁用是修饰器**:只清消息,槽位与选中态全部保留(复审 P2-1 那条)。 +#[test] +fn disabled_only_clears_the_message() { + let item = ListItem::new("Instances", ()) + .leading(unlucky_design::icons::IconKind::Instances) + .trailing(unlucky_design::icons::IconKind::Settings) + .supporting("3 versions") + .selected() + .disabled(); + assert!(item.on_press.is_none(), "禁用必须清消息"); + assert!(item.leading.is_some(), "禁用不许删前置图标"); + assert!(item.trailing.is_some(), "禁用不许删后置图标"); + assert!(item.selected, "禁用不许删选中态"); + assert!(item.supporting.is_some(), "禁用不许删槽位"); + assert_eq!(item.lines(), 2); +} + +/// 禁用内容角色是 **`OnSurface`**@38%(A1 ListTokens),不是默认的 `OnSurfaceVariant`。 +#[test] +fn disabled_content_uses_on_surface() { + let ui = ui(); + let palette = palette(&ui, Interaction::Disabled, false); + let expected = interaction::disabled_content_over_role(&ui, Role::OnSurface, false); + assert_eq!(palette.headline, expected); + assert_eq!(palette.leading, expected); + assert_eq!(palette.trailing, expected); + assert_ne!( + palette.headline, + interaction::disabled_content_over_role(&ui, Role::OnSurfaceVariant, false), + "走默认的 OnSurfaceVariant 就会偏浅一档(这条钉住 A1 的角色)" + ); + assert!(palette.fill.is_none(), "禁用不叠状态层容器"); +} + +/// 选中态:`SecondaryContainer` + `OnSecondaryContainer`,trailing 是 `Primary`(4.8)。 +#[test] +fn selected_row_is_secondary_container() { + let ui = ui(); + let palette = palette(&ui, Interaction::Enabled, true); + assert_eq!(palette.fill, Some(ui.color(Role::SecondaryContainer))); + assert_eq!(palette.headline, ui.color(Role::OnSecondaryContainer)); + assert_eq!(palette.trailing, ui.color(Role::Primary), "4.8"); +} + +/// 4.7:hover / pressed 只叠状态层,**内容色不变**。 +#[test] +fn interaction_states_keep_the_content_colors() { + let ui = ui(); + let resting = palette(&ui, Interaction::Enabled, false); + for state in [ + Interaction::Hovered, + Interaction::Focused, + Interaction::Pressed, + ] { + let active = palette(&ui, state, false); + assert_eq!(active.headline, resting.headline, "{state:?} 不许改文本色"); + assert_eq!(active.leading, resting.leading, "{state:?} 不许改图标色"); + assert!(active.fill.is_some(), "{state:?} 必须叠状态层"); + } + assert_eq!(resting.fill, None, "静息态不叠状态层"); +} + +/// 三档行高与 `LM.row_height` 的关系**钉死**(有人按 A1 改回 56 时这里会红)。 +#[test] +fn heights_are_pinned_to_the_row_height_token() { + let ui = ui(); + let geometry = ListItemGeometry::of(&ui); + assert_eq!(geometry.heights[0], ui.metrics().row_height); + assert_eq!(geometry.heights, [44.0, 60.0, 76.0]); + assert_ne!( + geometry.heights[0], 56.0, + "56 是 A1 的值;本项目按 12b §4.1 取 44(改回去会破坏页面排版)" + ); +} + +/// 元素构造不 panic(`Element` 树的形状也一并过一遍)。 +#[test] +fn view_builds_a_tree() { + let ui = ui(); + let _: Element<'_, ()> = list_item( + &ui, + ListItem::new("Instances", ()) + .overline("RECENT") + .supporting("3 versions") + .leading(unlucky_design::icons::IconKind::Instances) + .trailing(unlucky_design::icons::IconKind::Settings) + .selected(), + ); + let _: Element<'_, ()> = list_item(&ui, ListItem::new("Logs", ()).disabled()); +} diff --git a/crates/app-iced/src/components/navigation/matrix.rs b/crates/app-iced/src/components/navigation/matrix.rs new file mode 100644 index 0000000..9efd37f --- /dev/null +++ b/crates/app-iced/src/components/navigation/matrix.rs @@ -0,0 +1,110 @@ +//! 导航家族的长文案取证块(D-M79-5):**单行 + 裁切**,页面真接长实例名时不许折行。 +//! +//! 为什么不放在 `gallery/navigation.rs`:那个文件已经贴到 400 行红线(AGENTS.md §3-1), +//! 而它属于 harness 单文件、scope 只授权一个路径。按 `selection::matrix` 的先例,把**取证块** +//! 收进组件目录、对消息类型泛型(`pressed: impl Fn() -> M`),gallery 只做一次 `.map`。 + +use iced::widget::{column, container, text}; +use iced::{Background, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::theme::Ui; + +use super::{ + app_bar, list_item, navigation, tabs, AppBar, AppBarAction, ListItem, NavGroup, NavItem, + Navigation, Tab, Tabs, +}; + +/// 一行小字图例。 +fn note<'a, M: 'a>(ui: &'a Ui, value: &'a str) -> Element<'a, M> { + let style = ui.style(TypeRole::BodySmall); + text(value) + .size(style.size_px) + .font(ui.font_of(style)) + .color(ui.color(Role::OnSurfaceVariant)) + .into() +} + +/// 取景板(`SurfaceContainerLow`):`Surface` 的页头与页面底色同色,要垫一层才量得到边界。 +fn plate<'a, M: 'a>(ui: &'a Ui, pad: f32, content: impl Into>) -> Element<'a, M> { + container(content) + .padding(pad) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::SurfaceContainerLow))), + ..container::Style::default() + }) + .into() +} + +fn icon(name: &str) -> IconKind { + match name { + "download" => IconKind::Download, + "logs" => IconKind::Logs, + "settings" => IconKind::Settings, + _ => IconKind::Instances, + } +} + +/// 长文案块(D-M79-5 的取证):单行 + 裁切——不折行、不顶穿 56 的页头、不压 trailing。 +/// 矩阵图全用短文案看不出这个坑,真实实例名(`All the Mods 10 - ATM10 …`)一接就中招。 +pub fn long_part<'a, M: Clone + 'a>(ui: &'a Ui, pressed: impl Fn() -> M) -> Element<'a, M> { + const LONG: &str = "All the Mods 10 - ATM10 - 1.21.1 - NeoForge"; + + let bar = app_bar( + ui, + AppBar::new(LONG) + .subtitle(LONG) + .leading(AppBarAction::inert(icon("instances"))) + .trailing(AppBarAction::new(icon("settings"), pressed())) + .trailing(AppBarAction::new(icon("logs"), pressed())), + ); + + let row = |lines: usize| { + let mut item = ListItem::new(LONG, pressed()) + .leading(icon("instances")) + .trailing(icon("settings")); + if lines >= 2 { + item = item.supporting(LONG); + } + if lines >= 3 { + item = item.overline(LONG); + } + container(list_item(ui, item.selected())) + .width(Length::Fixed(ui.metrics().nav_drawer_width)) + }; + + let pane = navigation( + ui, + Navigation::drawer().group( + NavGroup::titled(LONG) + .item(NavItem::new(LONG, icon("instances"), pressed()).selected()) + .item(NavItem::new("Settings", icon("settings"), pressed())), + ), + ); + + // Tabs:长标签不折行,tab 自己变宽(横向滚动兜住溢出) + let tabs_row = tabs( + ui, + Tabs::primary() + .tab(Tab::new(LONG, pressed()).selected()) + .tab(Tab::new("Settings", pressed())), + ); + + column![ + note( + ui, + "长文案(D-M79-5):单行 + 裁切 —— 不折行、页头仍 56、不压 trailing" + ), + plate(ui, ui.space(Space::X2), bar), + column![row(1), row(2), row(3)].spacing(ui.space(Space::X4)), + tabs_row, + container(container(pane).height(Length::Fixed(ui.space(Space::X8) * 6.0))) + .width(Length::Fixed(ui.metrics().nav_drawer_width)), + ] + .spacing(ui.space(Space::X2)) + .into() +} diff --git a/crates/app-iced/src/components/navigation/mod.rs b/crates/app-iced/src/components/navigation/mod.rs index 6bbf2a0..de7d65c 100644 --- a/crates/app-iced/src/components/navigation/mod.rs +++ b/crates/app-iced/src/components/navigation/mod.rs @@ -1,35 +1,80 @@ -//! **导航类家族(桩)**:由后续 mission 填充(§4 ListItem / §6 Tabs / §21 NavigationDrawer / §16 AppBar)。 +//! **导航类家族**(`research/12b` §4/§6/§16 + `research/15` §21):四件组件,一个容器。 //! -//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**: -//! 家族模块已经在 `mod.rs` 里声明好,`gallery/navigation.rs` 也已经在 `gallery/mod.rs` 里声明好, -//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。 +//! | 组件 | 入口 | 参考章节 | 关键几何 | +//! |---|---|---|---| +//! | ListItem | [`list_item::list_item`] | 12b §4 | 行高 44 / 60 / 76(A1 56/72/88,登记偏离) | +//! | Tabs | [`tabs::tabs`] | 12b §6 | 容器 48、Primary 指示条 3、Secondary 线 1 | +//! | NavigationDrawer | [`drawer::navigation`] | 15 §21 + D33 | **同一个容器**:rail 88 ⇄ drawer 240 | +//! | AppBar small | [`app_bar::app_bar`] | 12b §16 | 页头 56、标题 TitleLarge、滚动换色 + L2 | //! -//! 填充时请照抄按钮家族的分层: +//! ## 三条纪律(与另外四个家族同源) //! -//! - 几何进 `navigation/geometry.rs`(数值从 `ComponentMetrics::NAVIGATION_DRAWER` / -//! `LayoutMetrics` / `Space` / `ShapeScale` 取,缺令牌登记 G 编号); -//! - 状态层只能走 [`crate::app::components::interaction`]; -//! - 精确尺寸用 [`crate::app::components::layout`]——导航选中指示器 **56×32** 就是 Q8 的历史事故现场, -//! 必须用 `layout::centered_fixed`(`center_x(Length::Fill)` 会把它撑成 88 宽)。 +//! 1. **对消息类型泛型**,不 import `crate::app::Message`(契约 §2);`Ui` 一律 `&'a Ui` 传入。 +//! 2. **状态层只走** [`crate::app::components::interaction`]:α 与禁用色一处求值; +//! 禁用是**修饰器**(`.disabled()` 只清消息,见 `selection/chip.rs` 复审 P2-1)。 +//! 3. **精确尺寸只走** [`crate::app::components::layout`]:图标一律 `layout::fixed`(Q9), +//! 居中交给父级 `align_x`(Q8:`center_x(Fill)` 会把 56 宽药丸撑成 88)。 +//! +//! ## 禁用内容角色**按变体**(不许抄默认值) +//! +//! | 组件 | 禁用内容角色 | 出处 | +//! |---|---|---| +//! | ListItem | **`OnSurface`**@38%(不是 `OnSurfaceVariant`) | `[源]` `ListTokens.ListItemDisabledLabel/LeadingIcon/TrailingIconColor = OnSurface` + `…Opacity = 0.38f` | +//! | Drawer / Rail 项 | `OnSurface`@38% | `[估]`(A1 无 disabled token,与 ListItem 同表面语境) | +//! | Tabs | `OnSurface`@38% | `[估]`(同上;A1 的 Tabs 两套 token 只有 Active/Inactive) | +//! | AppBar | — (本轮页头不带禁用动作) | — | +//! +//! ## Q21:要给居中就**两轴都显式给**(第三轮 review 的规则化) +//! +//! `Container` 的 `horizontal_alignment` 默认 `Left`、`vertical_alignment` 默认 `Top` +//! (`iced_widget-0.14.2/src/container.rs`)。只给 `align_y` 会让内容贴左缘——AppBar 的 40×40 +//! 动作按钮里 24px 图标曾因此左内缩 4 / 右内缩 20(视觉 r1 实测前导墨迹 `38..58`,应 `44..68`), +//! 而且 hover 的圆形状态层会露在图标一侧。 +//! +//! 本目录的复查结论(逐处): +//! +//! | 位置 | 需要 | 写法 | +//! |---|---|---| +//! | `app_bar::action` 命中区里的图标 | 两轴居中 | `align_x` + `align_y` | +//! | `app_bar` 页头内容行 | 垂直居中(水平由 `Fill` 决定) | `.align_y(Center)` | +//! | `tabs::tab_cell` 的内容 | 两轴居中 | `align_x` + `align_y` | +//! | `drawer/item` 的药丸 | 两轴居中 | `align_x` + `align_y` | +//! | `list_item` 的文本块 / `drawer` 的分组标题 | **左上起排**(`Start` 是对的,不需要居中) | 不设对齐,靠 `padding` 定起点 | +//! +//! 回归钉子:`navigation::probe` 的 appbar 断言量「左内缩 == 右内缩 ±2」——`align_x` 再漏一次就红。 +//! +//! ## 单行约束(偏离 D-M79-5) +//! +//! 四件的文本一律 `Wrapping::None` + 外层 `clip(true)`:容器高度都是固定值(44/60/76、48/64、56), +//! `Text` 默认 `Wrapping::Word` 会把长实例名折成 2~3 行,顶穿页头、压住 trailing。官方要的是 +//! **单行 ellipsis**,iced 没有 ellipsis(`inputs/menu.rs` 的 D19 先例)→ 我们单行 + 超长直接切。 +//! 取证:`ULL_NAV_PART=long` 出图 + `navigation::probe::long` 量「行高不变 / 页头仍 56 / 标题不压动作」。 +//! +//! ## 令牌缺口(G 编号,续 `research/15` §25 的 G13;G16~G20 已被其他家族占用) +//! +//! - **G22** Tabs 容器高 48(带图标 64)——`LayoutMetrics` 无此档; +//! - **G23** Tabs 指示条高 3 / `TabMinWidth` 90(后者 iced 无法表达,登记为限制); +//! - **G24** drawer 项高 56——与 `LM.nav_item_height`(44) 的差异按 A1 取 56,见 [`geometry`]; +//! - **G25** ListItem 两/三行高(60 / 76)——`G4` 要求的 `row_height_1/2/3` 仍缺,本文件按 +//! `LM.row_height` + 每行 16 派生(A1 delta 相同)。 +//! +//! ## 视觉证据(D30) +//! +//! `gallery/navigation.rs` 把四个组件摆成状态矩阵;`ULL_GALLERY_SECTION=navigation` 只渲染本段, +//! `ULL_NAV_PART=` 再切成单块出图(1180×760 一屏装不下四块矩阵)。 +//! 渲染层断言在 [`probe`](读 `ULL_NAV_DIR` 下的真截图量像素),纯函数断言在各组件文件的 `tests`。 -use iced::widget::{container, text}; -use iced::Element; +pub mod app_bar; +pub mod drawer; +pub mod geometry; +pub mod list_item; +pub mod matrix; +#[cfg(test)] +mod probe; +pub mod tabs; -use unlucky_design::color::Role; -use unlucky_design::spacing::Space; -use unlucky_design::typography::TypeRole; - -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("navigation · list item / tabs / drawer / app bar — pending") - .size(style.size_px) - .font(ui.font_of(style)) - .color(ui.color(Role::OnSurfaceVariant)), - ) - .padding(ui.space(Space::X2)) - .into() -} +pub use app_bar::{app_bar, AppBar, AppBarAction}; +pub use drawer::{navigation, NavGroup, NavItem, Navigation}; +pub use geometry::NavLayout; +pub use list_item::{list_item, ListItem}; +pub use tabs::{tabs, Tab, Tabs, TabsVariant}; diff --git a/crates/app-iced/src/components/navigation/probe.rs b/crates/app-iced/src/components/navigation/probe.rs new file mode 100644 index 0000000..68d2730 --- /dev/null +++ b/crates/app-iced/src/components/navigation/probe.rs @@ -0,0 +1,393 @@ +//! 渲染层几何断言:**对着真截图量像素**(M68 的教训——纯函数返回对,图可能是错的)。 +//! +//! ## 为什么自带一份最小读图器 +//! +//! `gallery::scan` 是 `pub(super)`(私有于 `gallery`),而 `gallery/mod.rs` 不在本 mission 的 scope 里, +//! 所以这里自带 ~60 行读图器(读 PNG / 取点 / 连通块 / 横带墨迹范围),判据全在本文件。 +//! 与 `inputs/probe.rs` 同一个处置(已作为 finding 报给 tower:建议把 `gallery::scan` 提升为 `pub(crate)`)。 +//! +//! ## 怎么跑(worktree 根目录) +//! +//! ```text +//! # ① 出 4 张图(每块一条命令,块选择器见 gallery/navigation.rs 的模块文档) +//! ULL_GALLERY_SECTION=navigation ULL_NAV_PART=list ./target/debug/unlucky-launcher-iced.exe \ +//! --software-renderer --screen=gallery --screenshot target/m79/list-light.png --screenshot-theme=light +//! # ② 量图 +//! ULL_NAV_DIR=target/m79 cargo test -p unlucky-app-iced navigation::probe +//! ``` +//! +//! 没给 `ULL_NAV_DIR` 就**大声跳过**(不静默通过),与 `gallery::probe` 的口径一致。 +//! +//! ## 量什么(每条都对着一个「已经画错过」的形态) +//! +//! | 断言 | 抓的错误 | +//! |---|---| +//! | ListItem 三行高 **44 / 60 / 76** | 抄 A1 的 56/72/88、或用内容撑高(行高被文本吃掉) | +//! | ListItem 禁用内容 = `OnSurface`@38% | 走 `disabled_colors()` 的默认 `OnSurfaceVariant` | +//! | Primary 指示条宽 = 选中项文本宽 + 2×16 | 条写成 `Fill`(= 整行宽,Q8 的另一面)或写死 | +//! | drawer **240** / rail **88**,药丸内缩 **12** / **16** | 药丸不贴边(Q8)、或两套组件各写死一个宽度 | +//! | AppBar **56** 高(静息 + 滚动) | 页头被内容撑高、或按 A1 改回 64 | + +use std::path::{Path, PathBuf}; + +use unlucky_design::color::{Role, SchemeFlavor}; +use unlucky_design::spacing::Space; + +use crate::app::components::interaction; + +mod divider; +mod long; +mod scan; +use scan::{rgb, ui as ui_of, BBox, Rgb, Shot}; + +/// 有这张图就读,没有就 `None`(深色档的可选覆盖用)。 +fn shot_opt(dir: &Path, part: &str, flavor: SchemeFlavor) -> Option { + let slug = match flavor { + SchemeFlavor::Light => "light", + SchemeFlavor::Dark => "dark", + }; + let path = dir.join(format!("{part}-{slug}.png")); + path.exists().then(|| Shot::load(&path)) +} + +/// `ULL_NAV_DIR` → 截图目录(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。 +fn shot_dir() -> Option { + let raw = std::env::var_os("ULL_NAV_DIR")?; + let path = PathBuf::from(raw); + if path.is_absolute() || path.exists() { + return Some(path); + } + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + Some(root.join(path)) +} + +macro_rules! require_dir { + () => { + match shot_dir() { + Some(dir) => dir, + None => { + eprintln!( + "[navigation::probe] SKIPPED:未设置 ULL_NAV_DIR。这是**渲染层**断言,\ + 需要先按本文件模块文档出 4 张图(target/m79/*.png)。" + ); + return; + } + } + }; +} + +fn shot(dir: &Path, part: &str, flavor: SchemeFlavor) -> Shot { + let slug = match flavor { + SchemeFlavor::Light => "light", + SchemeFlavor::Dark => "dark", + }; + Shot::load(&dir.join(format!("{part}-{slug}.png"))) +} + +/// ListItem:三行高 **44 / 60 / 76**(选中行的 `SecondaryContainer` 底就是行高)。 +#[test] +fn list_item_rows_are_44_60_76() { + let dir = require_dir!(); + let ui = ui_of(SchemeFlavor::Light); + let shot = shot(&dir, "list", SchemeFlavor::Light); + + // 矩阵格宽 240(`nav_drawer_width`):选中列的三块填充就是三条行 + let selected = rgb(ui.color(Role::SecondaryContainer)); + let mut rows: Vec = shot + .blocks(selected) + .into_iter() + .filter(|b| (238..=242).contains(&b.width()) && b.height() > 30) + .collect(); + rows.sort_by_key(|b| b.top); + assert_eq!( + rows.len(), + 3, + "选中列应有 3 条(一 / 两 / 三行),实际 {} —— 图里格子宽度或选中底丢了", + rows.len() + ); + let heights: Vec = rows.iter().map(BBox::height).collect(); + // 边缘那一行是抗锯齿(半覆盖)→ 实测常常少 1px;判 43..=44 / 59..=60 / 75..=76, + // 而 A1 的 56/72/88 仍然落区间外(这条照样能被打红)。 + for (measured, expected) in heights.iter().zip([44usize, 60, 76]) { + assert!( + (expected - 1..=expected).contains(measured), + "12b §4.1/4.2 的项目口径是 44/60/76(A1 是 56/72/88);实测 {heights:?}" + ); + } + + // 禁用内容角色 = `OnSurface`@38%(A1 ListTokens),不是默认的 `OnSurfaceVariant`@38%。 + // 判据:禁用格里**最深**的像素就是核心笔画,它必须精确等于 A1 的那个混合色。 + let cell = rows[0].width() + 8; // 格宽 240 + 列间距 8 + let disabled = BBox { + left: rows[0].left + cell * 2, + right: (rows[0].left + cell * 2 + rows[0].width() - 1).min(shot.width - 1), + top: rows[0].top, + bottom: rows[2].bottom, + }; + let darkest = shot.darkest(&disabled).expect("禁用格里应有墨迹"); + let right = rgb(interaction::disabled_content_over_role( + &ui, + Role::OnSurface, + false, + )); + let wrong = rgb(interaction::disabled_content_over_role( + &ui, + Role::OnSurfaceVariant, + false, + )); + let near = |a: Rgb, b: Rgb| { + a.iter() + .zip(b) + .all(|(x, y)| x.abs_diff(y) <= scan::TOLERANCE) + }; + assert!( + near(darkest, right), + "禁用内容最深像素是 {darkest:?},A1 的 OnSurface@38% 是 {right:?}(用 OnSurfaceVariant 会得到 {wrong:?})" + ); + assert!( + !near(darkest, wrong), + "禁用内容用了 OnSurfaceVariant@38%({wrong:?})而不是 A1 的 OnSurface@38%({right:?})" + ); + + eprintln!( + "[navigation::probe] list: 行高 {heights:?},禁用最深像素 {darkest:?}(A1 = {right:?})" + ); +} + +/// Tabs:Primary 指示条宽 = **选中项**文本宽 + 2×16(短/长文案各选中一次 → 两条宽度必须不同)。 +#[test] +fn tabs_indicator_tracks_the_selected_tab() { + let dir = require_dir!(); + let ui = ui_of(SchemeFlavor::Light); + let shot = shot(&dir, "tabs", SchemeFlavor::Light); + + let primary = rgb(ui.color(Role::Primary)); + let surface = rgb(ui.color(Role::Surface)); + // 指示条:3px 高、宽 ≥ 40 的 `Primary` 块(选中项的标签是同色,但笔画块远小于 40 宽) + let mut bars: Vec = shot + .blocks(primary) + .into_iter() + .filter(|b| b.height() <= 4 && b.width() >= 40) + .collect(); + bars.sort_by_key(|b| b.top); + assert!( + bars.len() >= 2, + "至少应有 2 条 Primary 指示条(短文案选中一行 + 长文案选中一行),实际 {}", + bars.len() + ); + // 前两条 = 两行 Primary(后面还有「五态」那一行的选中条,不参与「跟随宽度」的判定) + bars.truncate(2); + + let mut measured = Vec::new(); + for bar in &bars { + // 该行的标签墨迹:指示条上方 2..32 像素、**指示条自身跨度内**与底色不同的像素范围 + let span = shot + .band_ink( + surface, + bar.left, + bar.right, + bar.top.saturating_sub(32), + bar.top.saturating_sub(2), + ) + .expect("指示条上方应能找到选中标签的墨迹"); + let ink = span.1 - span.0 + 1; + let expected = ink + 32; // 2 × pad(16) + assert!( + bar.width() >= ink, + "指示条 {} 比标签墨迹 {ink} 还窄", + bar.width() + ); + assert!( + bar.width() <= expected + 12, + "指示条宽 {} 远大于「标签墨迹 {ink} + 2×16 = {expected}」——条被写成了整行宽(Q8)", + bar.width() + ); + assert!( + bar.width() + 12 >= expected, + "指示条宽 {} 小于「标签墨迹 {ink} + 2×16 = {expected}」——条没有覆盖内边距", + bar.width() + ); + measured.push((bar.width(), ink)); + } + // 6.6:Secondary 的分隔线必须是**一条连续直线**(逐 tab 分段画会在相邻段之间留缝)。 + // 判据与锚定规则全在 `probe/divider.rs`(含「无论红绿都打印锚定行」)。 + divider::check(&shot, &ui, "light"); + if let Some(dark) = shot_opt(&dir, "tabs", SchemeFlavor::Dark) { + divider::check(&dark, &ui_of(SchemeFlavor::Dark), "dark"); + } + + let (short_bar, long_bar) = (measured[0].0, measured[1].0); + assert!( + long_bar > short_bar + 20, + "两行的指示条宽度必须随选中项变化(短文案 {short_bar} / 长文案 {long_bar})——\ + 写死或 Fill 的条会一样宽" + ); + + eprintln!("[navigation::probe] tabs: 指示条 {measured:?}(宽, 标签墨迹宽)"); +} + +/// Drawer:**同一个容器两个宽度** —— rail 88 / drawer 240,药丸 56×32 / 216×56,内缩 16 / 12(D33)。 +#[test] +fn drawer_is_240_and_rail_is_88_with_inset_pills() { + let dir = require_dir!(); + let ui = ui_of(SchemeFlavor::Light); + let shot = shot(&dir, "drawer", SchemeFlavor::Light); + + let pad = Space::X2.dp(); + let plate = rgb(ui.color(Role::SurfaceContainerLow)); + let mut plates: Vec = shot + .blocks(plate) + .into_iter() + .filter(|b| b.height() > 150) + .collect(); + plates.sort_by_key(|b| b.left); + assert_eq!( + plates.len(), + 2, + "应有 2 块取景板(rail 与 drawer),实际 {}", + plates.len() + ); + let (rail_plate, drawer_plate) = (plates[0], plates[1]); + // 取景板比导航容器大一圈(左右各 pad):板宽 − 2×pad 才是容器宽 + let rail = rail_plate.width() as f32 - pad * 2.0; + let drawer = drawer_plate.width() as f32 - pad * 2.0; + assert_eq!(rail, 88.0, "LayoutMetrics::nav_rail_width"); + assert_eq!(drawer, 240.0, "LayoutMetrics::nav_drawer_width"); + + let pill = rgb(ui.color(Role::SecondaryContainer)); + let mut pills: Vec = shot + .blocks(pill) + .into_iter() + .filter(|b| b.width() > 40 && b.height() > 20) + .collect(); + pills.sort_by_key(|b| b.left); + assert_eq!( + pills.len(), + 2, + "rail 与 drawer 各一个选中药丸,实际 {}", + pills.len() + ); + let (rail_pill, drawer_pill) = (pills[0], pills[1]); + + // rail:药丸 56×32,左右各缩 16(88 − 2×16 = 56,M68 的结论) + // 边缘抗锯齿可能少 1px(32 → 31);判 ±1 区间,216/56 的 drawer 药丸同理 + assert!( + (55..=56).contains(&rail_pill.width()) && (31..=32).contains(&rail_pill.height()), + "12b §5.4:rail 药丸应是 56×32,实测 {}×{}", + rail_pill.width(), + rail_pill.height() + ); + assert_eq!( + rail_pill.left - (rail_plate.left + pad as usize), + 16, + "rail 药丸左内缩必须是 16(Q8 回归时这里是 0,药丸会贴边)" + ); + // drawer:药丸 216×56,左右各缩 12(A1 `NavigationDrawerItemDefaults.ItemPadding = 12`) + assert!( + (215..=216).contains(&drawer_pill.width()) && (55..=56).contains(&drawer_pill.height()), + "21.5:drawer 药丸应是 216×56(240 − 2×12),实测 {}×{}", + drawer_pill.width(), + drawer_pill.height() + ); + assert_eq!( + drawer_plate.width() - drawer_pill.width(), + 24 + pad as usize * 2, + "药丸两侧内缩之和必须是 24(12 + 12),再加取景板的两圈 pad" + ); + assert_eq!( + drawer_pill.left - (drawer_plate.left + pad as usize), + 12, + "ItemPadding" + ); + // 圆角语言不变:两条药丸都居中(左右内缩相等) + assert_eq!( + rail_plate.right - pad as usize - rail_pill.right, + rail_pill.left - (rail_plate.left + pad as usize), + "rail 药丸必须水平居中(两侧内缩相等)" + ); + + eprintln!( + "[navigation::probe] drawer: rail {rail} / drawer {drawer},药丸 {}×{} / {}×{},内缩 {} / {}", + rail_pill.width(), + rail_pill.height(), + drawer_pill.width(), + drawer_pill.height(), + rail_pill.left - (rail_plate.left + pad as usize), + drawer_pill.left - (drawer_plate.left + pad as usize) + ); +} + +/// AppBar:静息 56 高(`Surface`)、滚动 56 高(`SurfaceContainer`)——高度不随滚动变化。 +#[test] +fn app_bar_is_56_high_in_both_states() { + let dir = require_dir!(); + let ui = ui_of(SchemeFlavor::Light); + let shot = shot(&dir, "appbar", SchemeFlavor::Light); + + let resting = rgb(ui.color(Role::Surface)); + let scrolled = rgb(ui.color(Role::SurfaceContainer)); + let mut measured = Vec::new(); + for (name, color) in [("静息", resting), ("滚动", scrolled)] { + let bars: Vec = shot + .blocks(color) + .into_iter() + .filter(|b| b.width() > 300 && (54..=58).contains(&b.height())) + .collect(); + assert_eq!( + bars.len(), + 1, + "{name} AppBar 应恰好一条(56 高、>300 宽),实际 {} 条", + bars.len() + ); + measured.push((name, bars[0].height())); + } + // 两条同高:滚动只换容器色与高程,不改高度(A1 只有 `OnScrollContainerColor` / `OnScrollContainerElevation`) + // 滚动态带 L2 阴影,阴影会把边缘那一行染成接近容器的颜色 → 允许差 1px + assert!( + (measured[0].1 as i32 - measured[1].1 as i32).abs() <= 1, + "静息与滚动必须同高(滚动不改高度),实测 {measured:?}" + ); + // 抗锯齿让边缘行可能差 1px:判 55..=57;A1 的 64 必须落区间外 + assert!( + (55..=57).contains(&measured[0].1), + "AppBar 高度应是 56(LM.page_header_height),实测 {measured:?}" + ); + assert!( + !(63..=65).contains(&measured[0].1), + "64 是 A1 AppBarSmallTokens.ContainerHeight;本项目按骨架令牌取 56(D-M79-2)" + ); + + // Q21:动作图标必须在 40×40 命中区正中出现(`Container` 默认 Left/Top, + // 只给 `align_y` 会贴左缘——视觉 r1 实测前导墨迹 `38..58`,应 `44..68`)。 + // 判据:左内缩(bar.left → 前导墨迹左缘)== 右内缩(尾随墨迹右缘 → bar.right),±2px。 + let bars = shot + .blocks(resting) + .into_iter() + .filter(|b| b.width() > 300 && (54..=58).contains(&b.height())) + .collect::>(); + let bar = bars[0]; + let leading_ink = rgb(ui.color(Role::OnSurface)); + let trailing_ink = rgb(ui.color(Role::OnSurfaceVariant)); + let leading = shot + .band_extent(leading_ink, bar.top, bar.bottom, bar.left, bar.left + 48) + .expect("页头左侧应有前导图标墨迹"); + let trailing = shot + .band_extent( + trailing_ink, + bar.top, + bar.bottom, + bar.right.saturating_sub(48), + bar.right, + ) + .expect("页头右侧应有尾随图标墨迹"); + let left_inset = leading.0 as i32 - bar.left as i32; + let right_inset = bar.right as i32 - trailing.1 as i32; + assert!( + (left_inset - right_inset).abs() <= 2, + "动作图标不在命中区正中:左内缩 {left_inset} / 右内缩 {right_inset}(Q21:`align_x` 漏给会差 8px)" + ); + + eprintln!( + "[navigation::probe] appbar: 高度 {measured:?},图标内缩 左 {left_inset} / 右 {right_inset}" + ); +} diff --git a/crates/app-iced/src/components/navigation/probe/divider.rs b/crates/app-iced/src/components/navigation/probe/divider.rs new file mode 100644 index 0000000..966ae49 --- /dev/null +++ b/crates/app-iced/src/components/navigation/probe/divider.rs @@ -0,0 +1,49 @@ +//! 分隔线守卫(6.6):**Secondary 的分隔线必须是一条连续直线**。 +//! +//! 逐 tab 分段画会在相邻段之间留缝(视觉 r1 P2-2 实测 `25..101 | 103..191 | 193..265`, +//! 交界像素偏亮)。这条守卫被复审三轮打过三次,每次都是「假绿」,所以下面把**每个判据 +//! 挡的是哪一次假通过**写清楚——改之前先读一遍: +//! +//! | 判据 | 挡的假通过 | +//! |---|---| +//! | ① 锚定「细 + 近乎实心 + 薄 + 低对比 + 跨度 ≥200」的一行(见 `scan::Shot::find_thin_line`) | 五态行的 40px 高底色带、`Primary` 指示条顶替分隔线 | +//! | ② 线色必须是 `Surface → SurfaceVariant` 的插值(覆盖率 30%~105%) | 更靠上的同类细线顶替:`OutlineVariant` 组间线(t 出界)、hover 8% 带(三通道 t 散开) | +//! | ③ 参考色取**该行墨迹众数色**(见 `scan::Shot::line_segments`) | 缺口恰落在跨度中点时,参考色退化成页面底色 → 恒 1 段 | +//! | ④ 在该行**整段墨迹跨度**上数段(不是某次 run 的窗口) | 缺口在窗口外看不见、在窗口内被「跳过去重新起跑」滑过 | +//! +//! ## 锚定行**无条件打印** +//! +//! 不管红绿都往 stderr 打一行 `[probe] divider anchored at y=… span=x..x color=(r,g,b) segments=N`。 +//! 之前绿的时候什么都不说,于是「锚错行」是静默的——不看断言在量什么,等于没有断言。 +//! +//! ## 深浅两档 +//! +//! 浅色图必跑;深色图(`tabs-dark.png`)**存在就跑**(深色的 divider 对比更低,更值得测)。 +//! 没有深色图时不算失败,但会在输出里留一行 `dark 档跳过`。 + +use unlucky_design::color::Role; + +use crate::theme::Ui; + +use super::scan::{rgb, Shot}; + +/// 在 `shot` 上验证分隔线是一条直线;`label` 只用于日志(`light` / `dark`)。 +pub(super) fn check(shot: &Shot, ui: &Ui, label: &str) { + let surface = rgb(ui.color(Role::Surface)); + let variant = rgb(ui.color(Role::SurfaceVariant)); + let (y, x0, x1, color) = shot + .find_thin_line(surface, variant, 200, 120) + .unwrap_or_else(|| panic!("[{label}] 找不到 Secondary 那一行细线(锚定失败)")); + let segments = shot.line_segments(y, x0, x1); + eprintln!( + "[probe] divider anchored at y={y} span=x{x0}..{x1} color={color:?} segments={segments} \ + ({label}, {shot_width}x{shot_height})", + shot_width = shot.width, + shot_height = shot.height, + ); + assert_eq!( + segments, 1, + "[{label}] Secondary 分隔线应是一条直线,实测 {segments} 段(量到 y={y}, x{x0}..{x1})——\ + 分段画会在交界处留缝" + ); +} diff --git a/crates/app-iced/src/components/navigation/probe/long.rs b/crates/app-iced/src/components/navigation/probe/long.rs new file mode 100644 index 0000000..aa1295a --- /dev/null +++ b/crates/app-iced/src/components/navigation/probe/long.rs @@ -0,0 +1,98 @@ +//! 长文案的渲染层断言(**D-M79-5**:我们的单行约束是否真的成立)。 +//! +//! 矩阵图全用短文案("Instances" / "Instance details"),`Text` 默认 `Wrapping::Word` 的坑 +//! 在图上根本看不出来——页面一接真实实例名(`All the Mods 10 - ATM10 - 1.21.1 - NeoForge`) +//! 就会折成 2~3 行、顶穿 56 的页头、压住 trailing 动作。所以这里用 `ULL_NAV_PART=long` +//! 那张图单独量三件事: +//! +//! | 断言 | 抓的错误 | +//! |---|---| +//! | ListItem 三行高仍 44/60/76 | 长文本折行把行撑高 / 被固定行高裁掉 | +//! | AppBar 仍 56 高 | 标题折行顶穿页头 | +//! | 标题墨迹右缘 < trailing 图标左缘 | 长标题覆盖到动作按钮上 | +//! +//! 跑法见 `navigation/probe.rs` 的模块文档(`ULL_NAV_DIR=target/m79`,图名 `long-light.png`)。 + +use unlucky_design::color::{Role, SchemeFlavor}; + +use super::scan::{rgb, ui}; +use super::{shot, shot_dir}; + +#[test] +fn long_text_stays_single_line() { + let Some(dir) = shot_dir() else { + eprintln!( + "[navigation::probe::long] SKIPPED:未设置 ULL_NAV_DIR(需要先出 long-light.png)" + ); + return; + }; + let shot = shot(&dir, "long", SchemeFlavor::Light); + let ui = ui(SchemeFlavor::Light); + + // ---- ① ListItem:长实例名不折行 → 行高仍是 44 / 60 / 76 ---- + let selected = rgb(ui.color(Role::SecondaryContainer)); + let mut rows = shot + .blocks(selected) + .into_iter() + .filter(|b| (238..=242).contains(&b.width()) && b.height() > 30) + .collect::>(); + rows.sort_by_key(|b| b.top); + assert_eq!( + rows.len(), + 3, + "长文案块应有 3 条 ListItem(实 {})", + rows.len() + ); + let heights = rows.iter().map(|b| b.height()).collect::>(); + for (measured, expected) in heights.iter().zip([44usize, 60, 76]) { + assert!( + (expected - 1..=expected).contains(measured), + "长文案把行高撑成 {heights:?}(应为 44/60/76 ±1)——说明折行了" + ); + } + + // ---- ② AppBar:长标题不顶穿页头(仍 56 高)---- + let bar = shot + .blocks(rgb(ui.color(Role::Surface))) + .into_iter() + .find(|b| b.width() > 300 && (54..=58).contains(&b.height())) + .expect("长文案块应有一条 56 高的页头"); + assert!( + (55..=57).contains(&bar.height()), + "长标题把页头撑成 {} 高(应 56)——说明折行了", + bar.height() + ); + + // ---- ③ 标题不许压到 trailing 动作上 ---- + let title_ink = shot + .band_extent( + rgb(ui.color(Role::OnSurface)), + bar.top, + bar.bottom, + bar.left + 40, + bar.right.saturating_sub(90), + ) + .expect("页头里应有标题墨迹"); + let trailing = shot + .band_extent( + rgb(ui.color(Role::OnSurfaceVariant)), + bar.top, + bar.bottom, + bar.right.saturating_sub(90), + bar.right, + ) + .expect("页头右侧应有 trailing 图标墨迹"); + assert!( + title_ink.1 + 2 < trailing.0, + "长标题墨迹右缘 {} 压到 trailing 图标左缘 {} 上了(应被裁在文本区里)", + title_ink.1, + trailing.0 + ); + + eprintln!( + "[navigation::probe] long: 行高 {heights:?},页头 {} 高,标题右缘 {} < trailing {}", + bar.height(), + title_ink.1, + trailing.0 + ); +} diff --git a/crates/app-iced/src/components/navigation/probe/scan.rs b/crates/app-iced/src/components/navigation/probe/scan.rs new file mode 100644 index 0000000..1299db9 --- /dev/null +++ b/crates/app-iced/src/components/navigation/probe/scan.rs @@ -0,0 +1,379 @@ +//! `probe` 的读图器(`probe.rs` 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。 +//! +//! 只做三件事:读 PNG、找同色的连通块(`BBox`)、量一条横带里某颜色的墨迹范围 +//! 与「一条细线」。判据在 `probe.rs` / `probe/divider.rs` / `probe/long.rs`。 +//! +//! ## 已知边界(分隔线守卫,写在这里供以后改的人查) +//! +//! 1. **≤25% 覆盖的淡线找不到** → 表现是 **loud red**(`find_thin_line` 返回 `None`, +//! `divider::check` 直接 panic「锚定失败」),**不是假绿**。原因:`find_thin_line` 的 +//! `TOL=6` 是单通道可辨差下限,40% 覆盖行实测 `(9,6,4)` 刚好在线上,25% 覆盖行只有 +//! `(6,4,2)`,会落到判据外——但相邻那行是满覆盖(`(25,17,10)`),所以真实渲染的画法下 +//! 行锚定不会丢;只有「整条线都画在半像素上」才会既找不到又看起来像没画。 +//! 2. **身份校验绑定了 `SurfaceVariant`**:`divider::check` 用 +//! `Surface → SurfaceVariant` 的插值做身份校验。若哪天 Secondary 分隔线换了角色色 +//! (比如改回 `OutlineVariant`),这里会 loud red——届时改 `divider.rs` 里那一行 `Role`。 +//! 3. **只认「一条横线」**:竖向分隔线(本项目不用)与圆角/虚线画法不在判据内。 +//! 4. **深浅两档**:浅色图必跑;深色图存在就跑(`tabs-dark.png`),深色 divider 对比更低 +//! (实测线色 `(46,60,70)`,与深色 Surface 的差比浅色档小),更值得测。 + +use std::path::Path; + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; + +use crate::theme::{Families, Ui}; + +/// 字体族(与 `gallery::probe` 同一套)。 +pub(super) const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 颜色比对容差。 +/// +/// **3 而不是 10**:本家族要区分的几个容器色本身很接近(light 下 +/// `SurfaceContainerLow (235,245,255)` vs `SurfaceContainer (227,239,251)` 只差 8/6/4), +/// 容差 10 会把「取景板」和「滚动后的 AppBar」连成一块,量出来的高度就不对了。 +/// 实色区域是精确值,3 足够吸收抗锯齿的取整误差。 +pub(super) const TOLERANCE: u8 = 3; + +pub(super) type Rgb = [u8; 3]; + +pub(super) fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) +} + +pub(super) 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, + ] +} + +pub(super) fn matches(pixel: Rgb, expected: Rgb) -> bool { + // **每个通道**都在容差内;`differs` 是它的否定(任一通道超差即「不同」) + !differs(pixel, expected, TOLERANCE) +} + +/// 两个颜色在**每个通道**上都不超过 `tol`。 +pub(super) fn differs(pixel: Rgb, expected: Rgb, tol: u8) -> bool { + pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > tol) +} + +/// 一个外接框(闭区间)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct BBox { + pub(super) left: usize, + pub(super) right: usize, + pub(super) top: usize, + pub(super) bottom: usize, +} + +impl BBox { + pub(super) fn width(&self) -> usize { + self.right - self.left + 1 + } + + pub(super) fn height(&self) -> usize { + self.bottom - self.top + 1 + } +} + +/// 一张图的像素访问器。 +pub(super) struct Shot { + pub(super) width: usize, + pub(super) height: usize, + pixels: Vec, +} + +impl Shot { + pub(super) fn load(path: &Path) -> Self { + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读不动 {path:?}:{e}")); + let image = image::load_from_memory(&bytes) + .unwrap_or_else(|e| panic!("解 PNG 失败 {path:?}:{e}")) + .to_rgb8(); + assert_eq!( + (image.width() as usize, image.height() as usize), + (1180, 760), + "验收图必须是 D30 的 1180×760 @100% DPI" + ); + Self { + width: image.width() as usize, + height: image.height() as usize, + pixels: image.pixels().map(|p| p.0).collect(), + } + } + + pub(super) fn at(&self, x: usize, y: usize) -> Rgb { + self.pixels[y * self.width + x] + } + + /// 某种颜色的**全部**连通块(面积 ≥ 24;文字笔画太碎的块直接丢掉)。 + pub(super) fn blocks(&self, color: Rgb) -> Vec { + let mut seen = vec![false; self.width * self.height]; + let mut found = 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() >= 24 { + found.push(bbox); + } + } + } + found + } + + /// 矩形内**最深**的像素(禁用的核心笔画就是精确的混合色,用它判角色最稳)。 + pub(super) fn darkest(&self, bbox: &BBox) -> Option { + let mut best: Option<(u32, Rgb)> = None; + for y in bbox.top..=bbox.bottom.min(self.height - 1) { + for x in bbox.left..=bbox.right.min(self.width - 1) { + let pixel = self.at(x, y); + let sum: u32 = pixel.iter().map(|c| u32::from(*c)).sum(); + if best.is_none_or(|(current, _)| sum < current) { + best = Some((sum, pixel)); + } + } + } + best.map(|(_, pixel)| pixel) + } + + /// 矩形里某颜色墨迹的左右范围(量图标/文字位置用)。 + pub(super) fn band_extent( + &self, + color: Rgb, + y0: usize, + y1: usize, + x0: usize, + x1: usize, + ) -> Option<(usize, usize)> { + let mut span: Option<(usize, usize)> = None; + for y in y0..=y1.min(self.height - 1) { + for x in x0..=x1.min(self.width - 1) { + if matches(self.at(x, y), color) { + span = Some(match span { + Some((l, r)) => (l.min(x), r.max(x)), + None => (x, x), + }); + } + } + } + span + } + + /// 找「一条**细、低对比、近乎实心**的长横线」,返回 `(y, x0, x1)`: + /// `y` 是线最实的那一行,`x0..x1` 是**该行整段墨迹的跨度**(不是某一次连续 run 的窗口)。 + /// + /// 四条判据,每条都对应一次真实的假通过(视觉 r1 P2-2 复核时被点出来的): + /// + /// 1. **跨度 ≥ `min_len`**(200):`Secondary` 分隔线整行 242px;标题/标签行凑不满,能跳过它们。 + /// 2. **近乎实心**(墨迹数 ≥ 跨度 × 4/5):逐 tab 分段时中间是真缺口,但只有 1~2px,仍算实心 + /// ——**必须先能找到这一行**,再由 [`Shot::line_segments`] 把缺口数成多段。 + /// (早先「凑满 150px 就 return,然后在窗口里数段」的写法会滑过缺口,永远数到 1。) + /// 3. **薄**(跨中点纵向墨迹 ≤ 3 行):五态行里的 hover 底色是 40px 高的带,被这条排除 + /// (早先的写法会把那条 224px 的带选中,量到完全不同的元素)。 + /// 4. **低对比**(线色与底色的单通道最大差 ≤ `max_contrast`):`Primary` 指示条是高饱和色,被排除。 + /// + /// `TOL` 的口径:**单通道**最大可辨差,取 6。40% 覆盖的抗锯齿行实测单通道差 `(9,6,4)`, + /// 6 能看见它、又挡住 ±3 的渲染噪声;覆盖再低的行会落到判据外,但**相邻那行是满覆盖** + /// (实测线色差 25/17/10),所以行的锚定不会丢。 + pub(super) fn find_thin_line( + &self, + background: Rgb, + expected: Rgb, + min_len: usize, + max_contrast: u8, + ) -> Option<(usize, usize, usize, Rgb)> { + const TOL: u8 = 6; + let is_ink = |pixel: Rgb| differs(pixel, background, TOL); + let contrast = |pixel: Rgb| { + pixel + .iter() + .zip(background) + .map(|(p, q)| p.abs_diff(q)) + .max() + .unwrap_or(0) + }; + + for y in 0..self.height { + let ink: Vec = (0..self.width).filter(|x| is_ink(self.at(*x, y))).collect(); + let (Some(&first), Some(&last)) = (ink.first(), ink.last()) else { + continue; + }; + let len = last - first + 1; + if len < min_len || ink.len() * 5 < len * 4 { + continue; + } + // 薄:跨中点纵向墨迹 ≤ 3 行 + let mid = (first + last) / 2; + let mut top = y; + while top > 0 && is_ink(self.at(mid, top - 1)) { + top -= 1; + } + let mut bottom = y; + while bottom + 1 < self.height && is_ink(self.at(mid, bottom + 1)) { + bottom += 1; + } + if bottom - top + 1 > 3 { + continue; + } + // 低对比:取这条细带里最实的一行(满覆盖),跨段与线色都按那一行算 + let best = (top..=bottom) + .max_by_key(|candidate| { + (first..=last) + .filter(|x| is_ink(self.at(*x, *candidate))) + .count() + }) + .unwrap_or(y); + let line = self.modal_color(best, first, last); + let c = contrast(line); + if c > max_contrast || c <= TOL { + continue; + } + // **身份校验**:线色必须落在 `底色 → 预期角色色` 的插值线上(覆盖率 30%~105%)。 + // 只靠「跨度/薄/低对比」挡不住「更靠上的另一条同类细线」(复审 r3 的 + // `decoy_outline_gap` / `decoy_hover_gap` 两个反例都因此锚错行)。 + let Some(coverage) = Self::blend_coverage(line, background, expected) else { + eprintln!( + "[probe] divider skip y={best} color={line:?}(不是预期角色色的插值,拒绝)" + ); + continue; + }; + if !(0.30..=1.05).contains(&coverage) { + eprintln!( + "[probe] divider skip y={best} color={line:?}(覆盖率 {coverage:.2} 出界,拒绝)" + ); + continue; + } + return Some((best, first, last, line)); + } + None + } + + /// `y` 行 `[x0, x1]` 上「按该行**墨迹众数色**」数的连通段数(分隔线断没断)。 + /// + /// 参考色为什么不能取「跨度中点那一个像素」:缺口恰落在中点时,参考色变成页面底色, + /// 数出来的是「底色有几段」→ 恒为 1 → **假绿**(复审 r3 的 `gap_at_mid` 反例)。 + /// 众数色在整段跨度里天然是线自己的颜色(242px 的线 vs ≤4px 的缺口)。 + pub(super) fn line_segments(&self, y: usize, x0: usize, x1: usize) -> usize { + let line = self.modal_color(y, x0, x1); + let mut segments = 0; + let mut in_run = false; + for x in x0..=x1 { + let on = matches(self.at(x, y), line); + if on && !in_run { + segments += 1; + in_run = true; + } else if !on { + in_run = false; + } + } + segments + } + + /// `y` 行 `[x0, x1]` 里出现次数最多的颜色(分隔线的线色)。 + pub(super) fn modal_color(&self, y: usize, x0: usize, x1: usize) -> Rgb { + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + for x in x0..=x1.min(self.width - 1) { + *counts.entry(self.at(x, y)).or_insert(0) += 1; + } + counts + .into_iter() + .max_by_key(|(_, n)| *n) + .map(|(c, _)| c) + .unwrap_or(self.at(x0.min(self.width - 1), y)) + } + + /// `pixel` 是否落在 `from → to` 的插值线上(覆盖率 t),返回三个通道一致时的平均 t。 + /// + /// 用来给「这条线是不是某个角色色」做**身份校验**:比「单通道差 ≤N」稳得多—— + /// 实测 `OutlineVariant (200,212,224)` → t=(1.84,2.24,3.1) 出界; + /// hover 8% `(228,233,238)` → t=(0.72,1.0,1.7) 三通道散开 0.98;两者都被拒。 + /// 而真分隔线满覆盖 `(221,233,245)` → t=(1,1,1);40% 覆盖的抗锯齿行 → t≈(0.36,0.35,0.40) 被接受。 + /// + /// 只统计 `|to - from| > 4` 的通道(差太小的通道不携带信息,噪声会把 t 带偏)。 + pub(super) fn blend_coverage(pixel: Rgb, from: Rgb, to: Rgb) -> Option { + let mut ts = Vec::new(); + for ((p, f), t) in pixel.iter().zip(from).zip(to) { + let delta = f32::from(t) - f32::from(f); + if delta.abs() <= 4.0 { + continue; + } + ts.push((f32::from(*p) - f32::from(f)) / delta); + } + if ts.is_empty() { + return None; + } + let max = ts.iter().cloned().fold(f32::MIN, f32::max); + let min = ts.iter().cloned().fold(f32::MAX, f32::min); + if max - min > 0.25 { + return None; + } + Some(ts.iter().sum::() / ts.len() as f32) + } + + /// 矩形内「与底色不同」的像素的左右范围(量文本墨迹用)。 + /// + /// 为什么不按精确文字色找:小字号抗锯齿后几乎没有像素落在**精确**色上(容差 3 时实测 + /// 「Logs」只剩 12px)。改成「与底色不同即墨迹」,既与字色无关,也不会被状态层带偏。 + pub(super) fn band_ink( + &self, + background: Rgb, + x0: usize, + x1: usize, + y0: usize, + y1: usize, + ) -> Option<(usize, usize)> { + let mut span: Option<(usize, usize)> = None; + for y in y0..=y1.min(self.height - 1) { + for x in x0..=x1.min(self.width - 1) { + let pixel = self.at(x, y); + let differs = pixel + .iter() + .zip(background) + .any(|(a, b)| a.abs_diff(b) > 12); + if differs { + span = Some(match span { + Some((l, r)) => (l.min(x), r.max(x)), + None => (x, x), + }); + } + } + } + span + } +} diff --git a/crates/app-iced/src/components/navigation/tabs.rs b/crates/app-iced/src/components/navigation/tabs.rs new file mode 100644 index 0000000..a89d683 --- /dev/null +++ b/crates/app-iced/src/components/navigation/tabs.rs @@ -0,0 +1,359 @@ +//! Tabs(`research/12b` §6):**Primary**(下方 3px 指示条)与 **Secondary**(1px 分隔线)。 +//! +//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR) +//! +//! ```text +//! PrimaryNavigationTabTokens: ContainerHeight 48、IconAndLabelTextContainerHeight 64、 +//! ActiveIndicatorHeight 3、ActiveIndicatorShape RoundedCornerShape(3dp)、 +//! IconSize 24、ContainerShape CornerNone、LabelTextFont TitleSmall +//! ActiveIndicatorColor=Primary;Active*/Inactive* 各态内容色(见下表) +//! SecondaryNavigationTabTokens: ContainerHeight 48、DividerHeight、DividerColor=SurfaceVariant、 +//! ActiveLabelTextColor=OnSurface、InactiveLabelTextColor=OnSurfaceVariant +//! ``` +//! +//! | 项 | Primary | Secondary | +//! |---|---|---| +//! | 选中内容色 | **`Primary`** | **`OnSurface`**(不是 Primary——6.5 明写) | +//! | 未选内容色 | `OnSurfaceVariant` | `OnSurfaceVariant` | +//! | hover / focus / pressed | 未选项提到 **`OnSurface`**,选中项保持不变 | 同左 | +//! | 底部条 | 仅选中项:**3px `Primary`**,宽度**跟随该 tab** | 所有项:**1px `SurfaceVariant`**(连成一条线) | +//! +//! 尺寸/圆角/线厚全部来自 [`TabsGeometry`](**G22/G23**:`LayoutMetrics` 没有 Tabs 档)。 +//! +//! ## 指示条怎么做到「宽度跟随选中项」 +//! +//! iced 没有「取到渲染后文本宽度」的公开 API,所以这里用 [`stack`] 的**分层语义**: +//! +//! 1. `Stack` 的**底层**是 tab 自己的内容容器(`Length::Shrink` → 宽度 = 文本实测宽 + 2×16); +//! 2. `Stack` 的**上层**是 `Length::Fill` 的指示条。`Stack::layout` 先按**底层**定尺寸,再用 +//! 「已知尺寸」的 `Limits` 布局其余层(`iced_widget-0.14.2/src/stack.rs` 的 +//! `size = limits.resolve(self.width, self.height, base.size())`)——于是 Fill 的指示条 +//! 拿到的正好是「这个 tab 的宽」,**不是**整行宽。 +//! +//! 这也顺带避开 Q8 的另一面:把条写成 `center_x(Fill)` 会把它撑成整行。 +//! +//! ## 横向滚动 +//! +//! 整行外面套 `scrollable`(`Direction::Horizontal`,滚动条 `Scrollbar::hidden()`): +//! tab 宽度因此是**内容宽**而不是「行宽 ÷ n」——官方 `ScrollableTabRow` 的口径。 +//! `TabMinWidth = 90` **本轮不施加**(iced 无 min-width,登记在 G23)。 + +use iced::widget::text::{LineHeight, Wrapping}; +use iced::widget::{button, container, scrollable, space, text, Row, Stack}; +use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::typography::TypeRole; + +use crate::icons; +use crate::theme::Ui; + +use super::super::interaction::{self, Interaction}; +use super::super::layout; +use super::geometry::{TabsGeometry, TABS_ICON_HEIGHT}; + +/// 两种 Tabs(`research/12b` §6;M3 的 `PrimaryTabRow` / `SecondaryTabRow`)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TabsVariant { + /// 主标签页:选中项下方 3px `Primary` 指示条。 + Primary, + /// 次标签页:1px `SurfaceVariant` 分隔线 + 选中项 `OnSurface` 文字。 + Secondary, +} + +impl TabsVariant { + /// 两种变体(gallery 状态矩阵按这个次序出图)。 + pub const ALL: [TabsVariant; 2] = [TabsVariant::Primary, TabsVariant::Secondary]; + + /// 稳定名字(截图文件名 / 图例用)。 + pub const fn name(self) -> &'static str { + match self { + TabsVariant::Primary => "primary", + TabsVariant::Secondary => "secondary", + } + } + + /// 是否是 Primary(指示条 3px)。 + pub const fn is_primary(self) -> bool { + matches!(self, TabsVariant::Primary) + } +} + +/// 一个标签页。 +#[derive(Debug, Clone)] +pub struct Tab<'a, M> { + label: &'a str, + icon: Option, + selected: bool, + on_press: Option, + state: Option, +} + +impl<'a, M> Tab<'a, M> { + /// 一个未选中的 tab。 + pub fn new(label: &'a str, on_press: M) -> Self { + Self { + label, + icon: None, + selected: false, + on_press: Some(on_press), + state: None, + } + } + + /// 选中(内容色换成激活档 + 画指示条)。 + pub fn selected(mut self) -> Self { + self.selected = true; + self + } + + /// 前置图标(24,`IconSize::Toolbar`;带图标的容器高官方是 64,本轮只做文字档)。 + pub fn icon(mut self, kind: IconKind) -> Self { + self.icon = Some(kind); + self + } + + /// 强制视觉态(gallery 取证;不动状态机)。 + pub fn state(mut self, state: Interaction) -> Self { + self.state = Some(state); + self + } + + /// 转禁用:**只清消息**(修饰器,不是构造函数)。 + pub fn disabled(mut self) -> Self { + self.on_press = None; + self + } +} + +/// 一行标签页。 +#[derive(Debug, Clone)] +pub struct Tabs<'a, M> { + variant: TabsVariant, + tabs: Vec>, +} + +impl<'a, M> Tabs<'a, M> { + /// Primary tabs(3px 指示条)。 + pub fn primary() -> Self { + Self { + variant: TabsVariant::Primary, + tabs: Vec::new(), + } + } + + /// Secondary tabs(1px 分隔线)。 + pub fn secondary() -> Self { + Self { + variant: TabsVariant::Secondary, + tabs: Vec::new(), + } + } + + /// 追加一个 tab。 + pub fn tab(mut self, tab: Tab<'a, M>) -> Self { + self.tabs.push(tab); + self + } +} + +/// 一个 tab 的内容色(A1 的 `Active*` / `Inactive*` / 各态表,见模块文档)。 +fn content_color(ui: &Ui, primary: bool, state: Interaction, selected: bool) -> Color { + if state.is_disabled() { + // A1 的 Tabs 没有 Disabled* token(实读:Primary/Secondary 两套都只有 Active/Inactive × 四态), + // 按 ListItem 的 38% 口径处理 —— 登记 `[估]`。 + return interaction::disabled_content_over_role(ui, Role::OnSurface, false); + } + if selected { + // 6.5:Primary 选中 `Primary`;Secondary 选中 `OnSurface` + return if primary { + ui.color(Role::Primary) + } else { + ui.color(Role::OnSurface) + }; + } + match state { + // 未选中静息 `OnSurfaceVariant`;hover/focus/pressed 提到 `OnSurface`(A1 的 Inactive* 四态) + Interaction::Enabled => ui.color(Role::OnSurfaceVariant), + _ => ui.color(Role::OnSurface), + } +} + +/// Primary 指示条的颜色(只有选中项有;禁用项降档,否则「点不动却亮着」)。 +fn bar_color(ui: &Ui, state: Interaction, selected: bool) -> Option { + if !selected { + return None; + } + if state.is_disabled() { + return Some(interaction::disabled_content_over_role( + ui, + Role::OnSurface, + false, + )); + } + Some(ui.color(Role::Primary)) +} + +/// 6.6:Secondary 的分隔线色 = **`SurfaceVariant`**(不是 `OutlineVariant`——那是 §22 的分隔线)。 +/// +/// 色值只在这里出现一次:整行那条线与逐 tab 的实现都从这里取。 +fn secondary_line_color(ui: &Ui) -> Color { + ui.color(Role::SurfaceVariant) +} + +/// 一行标签页(自带横向滚动)。 +pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> { + let geometry = TabsGeometry::of(ui); + let primary = tabs.variant.is_primary(); + // 6.1:带图标时容器高 **64**(`IconAndLabelTextContainerHeight`),纯文字 48 + let height = if tabs.tabs.iter().any(|tab| tab.icon.is_some()) { + TABS_ICON_HEIGHT + } else { + geometry.height + }; + + let mut row = Row::new() + .height(Length::Fixed(height)) + .align_y(Alignment::Center); + for tab in tabs.tabs { + row = row.push(tab_cell(ui, tab, geometry, primary, height)); + } + + let body: Element<'a, M> = if primary { + row.into() + } else { + // A1 的 Secondary 分隔线是**整行一条直线**,不是每个 tab 一段: + // 逐段画会在相邻段之间留缝(视觉 r1 P2-2)。这里用 `Stack` 把一条 Fill 线压在整行底下, + // 底座是 tab 行本身(Shrink → 内容宽),所以线长 = tab 行总宽,横向滚动时跟着走。 + let line = container(space::vertical()) + .width(Length::Fill) + .height(Length::Fixed(geometry.secondary_bar)) + .style(move |_theme| container::Style { + background: Some(Background::Color(secondary_line_color(ui))), + ..container::Style::default() + }); + Stack::new() + .push(container(row)) + .push( + container(line) + .width(Length::Fill) + .height(Length::Fill) + .align_y(Alignment::End), + ) + .into() + }; + + scrollable(body) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::hidden(), + )) + .height(Length::Fixed(height)) + .into() +} + +/// 一个 tab 单元(内容 + 底部条)。 +fn tab_cell<'a, M: Clone + 'a>( + ui: &'a Ui, + tab: Tab<'a, M>, + geometry: TabsGeometry, + primary: bool, + height: f32, +) -> Element<'a, M> { + let forced = tab.state.or(if tab.on_press.is_none() { + Some(Interaction::Disabled) + } else { + None + }); + let state = forced.unwrap_or(Interaction::Enabled); + let selected = tab.selected; + let color = content_color(ui, primary, state, selected); + let style = ui.style(TypeRole::TitleSmall); + + let label = text(tab.label) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + // 单行(容器高固定 48/64):长标签让 tab 变宽 + 横向滚动,不折行(偏离 D-M79-5) + .wrapping(Wrapping::None) + .font(ui.font_of(style)) + .color(color); + + let mut content = Row::new().align_y(Alignment::Center).spacing(geometry.pad); + if let Some(kind) = tab.icon { + content = content.push(layout::fixed( + geometry.icon, + geometry.icon, + icons::view::(kind, color), + )); + } + content = content.push(label); + + let bar_height = geometry.bar(primary); + // 底层:内容自己决定宽度(Shrink),底部留出条的高度 —— 未选中的 Primary tab 也占同样的高 + let base = container(content) + .height(Length::Fixed(height)) + // Q21:要给居中就两轴都显式给(`Container` 默认 Left/Top) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .padding(Padding { + top: 0.0, + right: geometry.pad, + bottom: bar_height, + left: geometry.pad, + }); + + // Primary:逐 tab 画条(要跟随该 tab 的宽度);Secondary:整行一条,在 `tabs()` 里画 + let bar = if primary { + bar_color(ui, state, selected) + } else { + None + }; + let radius = geometry.indicator_radius(); + let mut stack = Stack::new().push(base); + if let Some(color) = bar { + // 上层:Fill —— `Stack` 先按底层定尺寸,所以这里的 Fill = 这个 tab 的宽(不是整行) + stack = stack.push( + container( + container(space::vertical()) + .width(Length::Fill) + .height(Length::Fixed(bar_height)) + .style(move |_theme| container::Style { + background: Some(Background::Color(color)), + border: Border { + radius, + ..Border::default() + }, + ..container::Style::default() + }), + ) + .width(Length::Fill) + .height(Length::Fill) + .align_y(Alignment::End), + ); + } + + let fill = interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface); + let button = button(stack) + .height(Length::Fixed(height)) + .padding(0) + .style(move |_theme, _status| button::Style { + background: fill.map(Background::Color), + text_color: color, + // ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上) + border: Border { + radius: ui.radius_dp(ShapeScale::None), + ..Border::default() + }, + shadow: Shadow::default(), + snap: true, + }); + + match tab.on_press { + Some(message) => button.on_press(message).into(), + None => button.into(), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/navigation/tabs/tests.rs b/crates/app-iced/src/components/navigation/tabs/tests.rs new file mode 100644 index 0000000..0446961 --- /dev/null +++ b/crates/app-iced/src/components/navigation/tabs/tests.rs @@ -0,0 +1,150 @@ +//! `tabs` 的单元测试(split 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。 + +use super::*; + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; + +use crate::theme::Families; + +use super::super::super::interaction; + +fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Light, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) +} + +/// 6.5:选中色 Primary 用 `Primary`,Secondary 用 `OnSurface`(**不是** Primary)。 +#[test] +fn active_colors_follow_the_variant() { + let ui = ui(); + assert_eq!( + content_color(&ui, true, Interaction::Enabled, true), + ui.color(Role::Primary) + ); + assert_eq!( + content_color(&ui, false, Interaction::Enabled, true), + ui.color(Role::OnSurface), + "6.5:Secondary 的选中项是 OnSurface" + ); + assert_ne!( + content_color(&ui, false, Interaction::Enabled, true), + ui.color(Role::Primary), + "把 Secondary 的选中色写成 Primary 是最常见的串线" + ); +} + +/// 未选中静息 `OnSurfaceVariant`;hover / focus / pressed 提到 `OnSurface`。 +#[test] +fn inactive_states_lift_to_on_surface() { + let ui = ui(); + for primary in [true, false] { + assert_eq!( + content_color(&ui, primary, Interaction::Enabled, false), + ui.color(Role::OnSurfaceVariant) + ); + for state in [ + Interaction::Hovered, + Interaction::Focused, + Interaction::Pressed, + ] { + assert_eq!( + content_color(&ui, primary, state, false), + ui.color(Role::OnSurface), + "{state:?} 必须提到 OnSurface" + ); + } + } +} + +/// 6.2:Primary 只有选中项有 3px `Primary` 条;禁用项降档。 +#[test] +fn primary_bar_only_on_the_selected_tab() { + let ui = ui(); + assert_eq!(bar_color(&ui, Interaction::Enabled, false), None); + assert_eq!( + bar_color(&ui, Interaction::Enabled, true), + Some(ui.color(Role::Primary)) + ); + assert_eq!( + bar_color(&ui, Interaction::Disabled, true), + Some(interaction::disabled_content_over_role( + &ui, + Role::OnSurface, + false + )), + "禁用项「点不动却亮着」是错的" + ); +} + +/// 6.6:Secondary 的分隔线整行一条,颜色 `SurfaceVariant`(不是 §22 的 `OutlineVariant`)。 +#[test] +fn secondary_line_is_surface_variant() { + let ui = ui(); + assert_eq!(secondary_line_color(&ui), ui.color(Role::SurfaceVariant)); + assert_ne!( + secondary_line_color(&ui), + ui.color(Role::OutlineVariant), + "OutlineVariant 是 §22 分隔线的角色,Tabs 不用它" + ); +} + +/// 禁用是**修饰器**:只清消息,label / 选中态 / 图标全部保留。 +#[test] +fn disabled_only_clears_the_message() { + let tab = Tab::new("Mods", ()) + .icon(unlucky_design::icons::IconKind::Settings) + .selected() + .disabled(); + assert!(tab.on_press.is_none()); + assert!(tab.icon.is_some()); + assert!(tab.selected); + assert_eq!(tab.label, "Mods"); +} + +/// 指示条宽度 = tab 宽 = 文本宽 + 2×16(这条是「跟随选中项宽度」的公式面)。 +#[test] +fn indicator_width_is_the_tab_width() { + let geometry = TabsGeometry::of(&ui()); + let tab_width = |text_width: f32| text_width + geometry.pad * 2.0; + assert_eq!(tab_width(0.0), 32.0, "空文本 = 2×16"); + // 两段不同长度的文案 ⇒ 指示条宽度必然不同(渲染层断言用它打红) + let short = tab_width(40.0); + let long = tab_width(120.0); + assert_eq!(long - short, 80.0); + assert!(long > short); +} + +/// 6.1:带图标的 Tabs 容器高 64(`IconAndLabelTextContainerHeight`),纯文字 48。 +#[test] +fn icon_tabs_use_the_taller_container() { + let ui = ui(); + assert_eq!(TABS_ICON_HEIGHT, 64.0); + assert!(TABS_ICON_HEIGHT > TabsGeometry::of(&ui).height); +} + +/// 3 个 tab 的树能建出来(含选中 / 禁用 / 带图标三条路径)。 +#[test] +fn view_builds_a_tree() { + let ui = ui(); + let _: Element<'_, ()> = tabs( + &ui, + Tabs::primary() + .tab(Tab::new("General", ()).selected()) + .tab(Tab::new("Mods", ()).icon(unlucky_design::icons::IconKind::Settings)) + .tab(Tab::new("Logs", ()).disabled()), + ); + let _: Element<'_, ()> = tabs( + &ui, + Tabs::secondary() + .tab(Tab::new("Release", ()).selected()) + .tab(Tab::new("Snapshot", ())), + ); +}