From a36f542f28b5132d0f4e1ca8f88c471683d5acec Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 10:11:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M79=20r2=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E5=9B=9B=E6=9D=A1=20review=20=E4=BF=AE=E5=A4=8D=EF=BC=88?= =?UTF-8?q?=E5=AF=B9=E9=BD=90/=E5=8D=95=E8=A1=8C/=E5=8F=AF=E6=AF=94?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1/=E6=95=B4=E8=A1=8C=E5=88=86=E9=9A=94?= =?UTF-8?q?=E7=BA=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代码 r1 P2-1 = 视觉 r1 P2-1(同一处,必修) - `app_bar::action`:容器只给了 `align_y` → 24px 图标贴 40×40 命中区左缘(实测前导墨迹 `38..58`,应 `44..68`;hover 的圆形状态层也露在图标一侧)。补 `align_x(Center)`; 同时页头内容行补 `align_y(Center)`(`Container` 默认 Top,整行原本贴顶)。 - 把 Q21 升格成规则并全目录复查(结论写进 `navigation/mod.rs` 的表): app_bar 命中区/页头行、tabs tab 内容、drawer 药丸 = 两轴显式居中; list_item 文本块与 drawer 分组标题 = 左上起排(`Start` 是对的,不需要居中)。 回归钉子:`navigation::probe` 的 appbar 断言量「左内缩 == 右内缩 ±2」。 代码 r1 P2-2(必修):四件文本都没有单行约束,容器高度却是固定值 - `app_bar`(标题/副标题)、`list_item::line`、`drawer::line`、`tabs` 标签统一 `.wrapping(Wrapping::None)` + 外层 `clip(true)`;页头/ListiItem/drawer 的文本块改成 `Fill + clip`,长文本切在文本区里,不折行、不顶穿、不压 trailing 动作。 - iced 没有 ellipsis → 按 `inputs/menu.rs` 的 D19 先例登记 **D-M79-5** (官方要单行 ellipsis,我们单行 + 超长直接切割)。 - 取证:gallery 新增 `ULL_NAV_PART=long`(长实例名整块)+ `navigation/probe/long.rs` 量「行高仍 44/60/76 / 页头仍 56 / 标题墨迹右缘 < trailing 左缘」。取证块按 `selection::matrix` 的先例收进 `navigation/matrix.rs`(gallery 单文件贴 400 红线)。 视觉 r1 P2-2(必修):ListItem 三列内容不同 → 「选中 trailing = Primary」没有可比对象 - 三列都挂 `trailing(settings)`,只有 selected 列走 `Primary`(其余 `OnSurface` / 38%)。 视觉 r1 P2-2(可选,已做):Secondary 分隔线逐 tab 分段 + 0.5 圆角留了 1px 亮缝 - 改成**整行一条**:`Stack` 把一条 `Fill` 的 1px `SurfaceVariant` 压在 tab 行底下 (底座是 tab 行本身 → 线长 = tab 行总宽,横向滚动跟着走)。 - `TabsGeometry::bar_radius(bool)` 收成 `indicator_radius()`(只有 3px 的 Primary 条要圆角), 单测同步;probe 新增「Secondary 分隔线必须 1 段」的断言。 门禁:`cargo fmt --all --check` ✓ · `cargo build -p unlucky-app-iced --lib` 零 warning ✓ (截图/探针/测试的实时输出见完成报告;本轮宿主机被多条并行线的 rustc 打满, bin 链接排队严重,如未取到会如实标注未完成项。) --- .../src/components/gallery/navigation.rs | 53 ++++----- .../src/components/navigation/app_bar.rs | 20 +++- .../src/components/navigation/drawer/item.rs | 41 ++++--- .../src/components/navigation/geometry.rs | 9 +- .../components/navigation/geometry/tests.rs | 3 +- .../src/components/navigation/list_item.rs | 13 ++- .../src/components/navigation/matrix.rs | 110 ++++++++++++++++++ .../app-iced/src/components/navigation/mod.rs | 27 +++++ .../src/components/navigation/probe.rs | 52 ++++++++- .../src/components/navigation/probe/long.rs | 99 ++++++++++++++++ .../src/components/navigation/probe/scan.rs | 23 ++++ .../src/components/navigation/tabs.rs | 78 +++++++++---- .../src/components/navigation/tabs/tests.rs | 31 +++-- 13 files changed, 471 insertions(+), 88 deletions(-) create mode 100644 crates/app-iced/src/components/navigation/matrix.rs create mode 100644 crates/app-iced/src/components/navigation/probe/long.rs diff --git a/crates/app-iced/src/components/gallery/navigation.rs b/crates/app-iced/src/components/gallery/navigation.rs index eeac1f3..1ca5830 100644 --- a/crates/app-iced/src/components/gallery/navigation.rs +++ b/crates/app-iced/src/components/gallery/navigation.rs @@ -1,35 +1,28 @@ -//! gallery 里的导航类小节:四块状态矩阵(**断言在 `navigation::probe`,不在这里**)。 +//! gallery 里的导航类小节:四块状态矩阵 + 一块长文案块(**断言在 `navigation::probe`**)。 //! -//! ## 为什么再切一层 `ULL_NAV_PART` +//! ## 块选择器 `ULL_NAV_PART` //! -//! `ULL_GALLERY_SECTION=navigation` 只解决「别的家族」;本家族自己就有四块矩阵, -//! 竖排起来比 760 还高,一屏只拍得到第一块。所以这里加**块选择器**(契约 §9.2 的同一套规则: -//! 环境变量 + 默认向后兼容 + 未识别值回落并打 stderr): +//! `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 # 只渲染 ListItem 矩阵 -//! ULL_NAV_PART=tabs | drawer | appbar | all(默认 all) -//! ``` -//! -//! 出图命令(worktree 根目录): -//! -//! ```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_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(短文案 / 长文案各选中一次)+ 一行 Secondary + 一行五态 -//! Drawer rail 88 与 drawer 240 **同尺度并排**(D33:同一容器两个宽度),各 3 项、第 2 项选中 -//! AppBar 静息 / 滚动两态(56 高;滚动换容器色 + L2) +//! 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 就是靠这块板的 bbox 量 88 / 240 的)。 +//! 取景板(`SurfaceContainerLow`)不是装饰:`Surface` 的页头/导航与页面底色同色,不垫一层板子, +//! 截图里量不到边界(probe 就是靠板量 88 / 240 的)。 use iced::widget::{column, container, row, text}; use iced::{Background, Element, Length}; @@ -43,7 +36,7 @@ use crate::theme::Ui; use super::super::interaction::Interaction; use super::super::navigation::{ - app_bar, list_item, navigation, tabs, AppBar, AppBarAction, ListItem, NavGroup, NavItem, + self, app_bar, list_item, navigation, tabs, AppBar, AppBarAction, ListItem, NavGroup, NavItem, NavLayout, Navigation, Tab, Tabs, TabsVariant, }; use super::{heading, DemoMessage}; @@ -58,9 +51,11 @@ fn part() -> String { }; let name = raw.trim().to_ascii_lowercase(); match name.as_str() { - "" | "all" | "list" | "tabs" | "drawer" | "appbar" => name, + "" | "all" | "list" | "tabs" | "drawer" | "appbar" | "long" => name, _ => { - eprintln!("[gallery] {PART_ENV}={raw:?} 不是已知块(all|list|tabs|drawer|appbar),已回落 all"); + eprintln!( + "[gallery] {PART_ENV}={raw:?} 不是已知块(all|list|tabs|drawer|appbar|long),已回落 all" + ); "all".to_owned() } } @@ -128,6 +123,9 @@ pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { 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() } @@ -162,8 +160,11 @@ fn list_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { 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 { - // 三列的**内容完全相同**(同样的槽位),只有态不同——否则「选中 vs 未选中」没法逐像素比 - let mut item = ListItem::new(lines, pressed()).leading(icon("instances")); + // 三列的**内容完全相同**(同样的槽位),只有态不同——否则「选中 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"); } @@ -171,7 +172,7 @@ fn list_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { item = item.overline("RECENT"); } item = match state { - "selected" => item.selected().trailing(icon("settings")), + "selected" => item.selected(), // 禁用是修饰器:这里只清消息,槽位全部保留 "disabled" => item.disabled(), _ => item.state(Interaction::Enabled), diff --git a/crates/app-iced/src/components/navigation/app_bar.rs b/crates/app-iced/src/components/navigation/app_bar.rs index 12cb622..0f6e5a6 100644 --- a/crates/app-iced/src/components/navigation/app_bar.rs +++ b/crates/app-iced/src/components/navigation/app_bar.rs @@ -32,7 +32,7 @@ //! - 不做 `Medium` / `Large` 的**折叠(collapsing)**形态——那是另外两个组件(`AppBarMediumTokens` //! / `AppBarLargeTokens`),本 mission 的 §16 只要求 small。 -use iced::widget::text::LineHeight; +use iced::widget::text::{LineHeight, Wrapping}; use iced::widget::{button, container, text}; use iced::{ Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow, Vector, @@ -138,10 +138,14 @@ fn action<'a, M: Clone + 'a>( 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)) @@ -182,6 +186,8 @@ pub fn app_bar<'a, M: Clone + 'a>(ui: &'a Ui, bar: AppBar<'a, M>) -> Element<'a, 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)), ); @@ -191,6 +197,7 @@ pub fn app_bar<'a, M: Clone + 'a>(ui: &'a Ui, bar: AppBar<'a, M>) -> Element<'a, 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)), ); @@ -203,9 +210,10 @@ pub fn app_bar<'a, M: Clone + 'a>(ui: &'a Ui, bar: AppBar<'a, M>) -> Element<'a, // 16.5:leading 图标 `OnSurface` row = row.push(action(ui, leading, geometry, ui.color(Role::OnSurface))); } - row = row.push(texts); - // 标题与后置动作之间必须有 Fill:否则标题会被推到右边或右侧动作贴住标题(M77 Q8 同款) - row = row.push(iced::widget::space::horizontal()); + // 文本块吃掉剩余宽度并**裁切**:这一条同时解决两件事—— + // ① 标题与 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( @@ -239,6 +247,10 @@ pub fn app_bar<'a, M: Clone + 'a>(ui: &'a Ui, bar: AppBar<'a, M>) -> Element<'a, 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, diff --git a/crates/app-iced/src/components/navigation/drawer/item.rs b/crates/app-iced/src/components/navigation/drawer/item.rs index 0085df9..4b4a667 100644 --- a/crates/app-iced/src/components/navigation/drawer/item.rs +++ b/crates/app-iced/src/components/navigation/drawer/item.rs @@ -3,7 +3,7 @@ //! 拆出去的是「一项长什么样」(`NavItem` / `NavGroup` + 颜色求值 + 一行布局), //! `drawer.rs` 只留「容器长什么样」(宽度、圆角、分组标题、分隔线)。 -use iced::widget::text::LineHeight; +use iced::widget::text::{LineHeight, Wrapping}; use iced::widget::{button, container, text}; use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow}; @@ -164,6 +164,8 @@ pub(super) fn line<'a, M: 'a>( 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() @@ -217,12 +219,18 @@ pub(super) fn nav_row<'a, M: Clone + 'a>( .width(Length::Fixed(geometry.width)) .align_x(Alignment::Center); stack = stack.push(pill(icon)); - stack = stack.push(line::( - ui, - item.label, - TypeRole::LabelMedium, - colors.label, - )); + // 标签要裁到轨宽:单行超长时切在 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 => { @@ -236,15 +244,20 @@ pub(super) fn nav_row<'a, M: Clone + 'a>( bottom: 0.0, left: geometry.pad_start, }); + // label 吃掉剩余宽度并裁切(单行 + 不压到药丸右内边距,D-M79-5) pill(Element::from( row.push(icon) - .push(line::( - ui, - item.label, - TypeRole::LabelLarge, - colors.label, - )) - .push(iced::widget::space::horizontal()), + .push( + container(line::( + ui, + item.label, + TypeRole::LabelLarge, + colors.label, + )) + .width(Length::Fill) + .clip(true), + ) + .width(Length::Fill), )) } }; diff --git a/crates/app-iced/src/components/navigation/geometry.rs b/crates/app-iced/src/components/navigation/geometry.rs index 8b2a019..1e1b88e 100644 --- a/crates/app-iced/src/components/navigation/geometry.rs +++ b/crates/app-iced/src/components/navigation/geometry.rs @@ -197,9 +197,12 @@ impl TabsGeometry { } } - /// 指示条圆角:`RoundedCornerShape(3dp)` 落在 3px 高的条上,与 `Full`(=1.5 半径)渲染结果相同。 - pub fn bar_radius(&self, primary: bool) -> Radius { - Radius::new(self.bar(primary) / 2.0) + /// 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) } } diff --git a/crates/app-iced/src/components/navigation/geometry/tests.rs b/crates/app-iced/src/components/navigation/geometry/tests.rs index ef61433..263832b 100644 --- a/crates/app-iced/src/components/navigation/geometry/tests.rs +++ b/crates/app-iced/src/components/navigation/geometry/tests.rs @@ -115,8 +115,7 @@ fn tabs_geometry_matches_the_reference() { 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.bar_radius(true).top_left, 1.5); - assert_eq!(geometry.bar_radius(false).top_left, 0.5); + assert_eq!(geometry.indicator_radius().top_left, 1.5); } /// 16.1 / 16.5:AppBar 56(LM)、图标 24、左右 4、头像 32。 diff --git a/crates/app-iced/src/components/navigation/list_item.rs b/crates/app-iced/src/components/navigation/list_item.rs index 7a7864d..3f44d0a 100644 --- a/crates/app-iced/src/components/navigation/list_item.rs +++ b/crates/app-iced/src/components/navigation/list_item.rs @@ -38,8 +38,8 @@ //! `SecondaryContainer` 表示「当前项」——三处保持同一语言,故 ListItem 也填 `SecondaryContainer` //! + `OnSecondaryContainer`。**这是实质偏离**:A1 的 ListItem 选中行不换底色。 -use iced::widget::text::LineHeight; -use iced::widget::{button, container, space, text}; +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; @@ -193,6 +193,10 @@ fn line<'a, M: 'a>( 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() @@ -246,10 +250,9 @@ pub fn list_item<'a, M: Clone + 'a>(ui: &'a Ui, item: ListItem<'a, M>) -> Elemen icons::view::(kind, colors.leading), )); } - content = content.push(body); + // 文本块吃掉剩余宽度并**裁切**:单行超长时切在行内边距上,不会压到 trailing + content = content.push(container(body).width(Length::Fill).clip(true)); if let Some(kind) = item.trailing { - // trailing 必须在行尾:label 与 trailing 之间插一条水平 Fill(`inputs/menu.rs` 的现行写法) - content = content.push(space::horizontal()); content = content.push(layout::fixed( geometry.icon, geometry.icon, 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 383af87..de7d65c 100644 --- a/crates/app-iced/src/components/navigation/mod.rs +++ b/crates/app-iced/src/components/navigation/mod.rs @@ -24,6 +24,32 @@ //! | 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` 无此档; @@ -42,6 +68,7 @@ pub mod app_bar; pub mod drawer; pub mod geometry; pub mod list_item; +pub mod matrix; #[cfg(test)] mod probe; pub mod tabs; diff --git a/crates/app-iced/src/components/navigation/probe.rs b/crates/app-iced/src/components/navigation/probe.rs index d0b1f75..3a6167b 100644 --- a/crates/app-iced/src/components/navigation/probe.rs +++ b/crates/app-iced/src/components/navigation/probe.rs @@ -35,11 +35,12 @@ use unlucky_design::spacing::Space; use crate::app::components::interaction; +mod long; mod scan; use scan::{rgb, ui, BBox, Rgb, Shot}; /// `ULL_NAV_DIR` → 截图目录(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。 -fn shot_dir() -> Option { +pub(super) fn shot_dir() -> Option { let raw = std::env::var_os("ULL_NAV_DIR")?; let path = PathBuf::from(raw); if path.is_absolute() || path.exists() { @@ -64,7 +65,7 @@ macro_rules! require_dir { }; } -fn shot(dir: &Path, part: &str, flavor: SchemeFlavor) -> Shot { +pub(super) fn shot(dir: &Path, part: &str, flavor: SchemeFlavor) -> Shot { let slug = match flavor { SchemeFlavor::Light => "light", SchemeFlavor::Dark => "dark", @@ -197,6 +198,19 @@ fn tabs_indicator_tracks_the_selected_tab() { ); measured.push((bar.width(), ink)); } + // 6.6:Secondary 的分隔线必须是**一条连续直线**(逐 tab 分段画会在相邻段之间留 1px 亮缝, + // 视觉 r1 P2-2 实测 `25..101 | 103..191 | 193..265`) + let divider = rgb(ui.color(Role::SurfaceVariant)); + let segments = shot + .blocks(divider) + .into_iter() + .filter(|b| b.height() <= 2 && b.width() >= 20) + .count(); + assert_eq!( + segments, 1, + "Secondary 分隔线应是一条直线,实测 {segments} 段(分段画会留缝)" + ); + let (short_bar, long_bar) = (measured[0].0, measured[1].0); assert!( long_bar > short_bar + 20, @@ -338,5 +352,37 @@ fn app_bar_is_56_high_in_both_states() { "64 是 A1 AppBarSmallTokens.ContainerHeight;本项目按骨架令牌取 56(D-M79-2)" ); - eprintln!("[navigation::probe] appbar: 高度 {measured:?}"); + // 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/long.rs b/crates/app-iced/src/components/navigation/probe/long.rs new file mode 100644 index 0000000..bf1748d --- /dev/null +++ b/crates/app-iced/src/components/navigation/probe/long.rs @@ -0,0 +1,99 @@ +//! 长文案的渲染层断言(**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() + .filter(|b| b.width() > 300 && (54..=58).contains(&b.height())) + .next() + .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 index 1428238..8487132 100644 --- a/crates/app-iced/src/components/navigation/probe/scan.rs +++ b/crates/app-iced/src/components/navigation/probe/scan.rs @@ -155,6 +155,29 @@ impl Shot { 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 + } + /// 矩形内「与底色不同」的像素的左右范围(量文本墨迹用)。 /// /// 为什么不按精确文字色找:小字号抗锯齿后几乎没有像素落在**精确**色上(容差 3 时实测 diff --git a/crates/app-iced/src/components/navigation/tabs.rs b/crates/app-iced/src/components/navigation/tabs.rs index 80a66a9..a89d683 100644 --- a/crates/app-iced/src/components/navigation/tabs.rs +++ b/crates/app-iced/src/components/navigation/tabs.rs @@ -38,7 +38,7 @@ //! tab 宽度因此是**内容宽**而不是「行宽 ÷ n」——官方 `ScrollableTabRow` 的口径。 //! `TabMinWidth = 90` **本轮不施加**(iced 无 min-width,登记在 G23)。 -use iced::widget::text::LineHeight; +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}; @@ -181,25 +181,26 @@ fn content_color(ui: &Ui, primary: bool, state: Interaction, selected: bool) -> } } -/// 指示条 / 分隔线的颜色(Primary 只给选中项;Secondary 每项都有)。 -fn bar_color(ui: &Ui, primary: bool, state: Interaction, selected: bool) -> Option { - if primary { - // 禁用项的指示条也降档(否则「点不动却亮着」) - if !selected { - return None; - } - if state.is_disabled() { - return Some(interaction::disabled_content_over_role( - ui, - Role::OnSurface, - false, - )); - } - Some(ui.color(Role::Primary)) - } else { - // 6.6:Secondary 的分隔线 `SurfaceVariant`(不是 OutlineVariant——那是 §22 的分隔线) - Some(ui.color(Role::SurfaceVariant)) +/// 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) } /// 一行标签页(自带横向滚动)。 @@ -220,7 +221,31 @@ pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> row = row.push(tab_cell(ui, tab, geometry, primary, height)); } - scrollable(row) + 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(), )) @@ -249,6 +274,8 @@ fn tab_cell<'a, M: Clone + 'a>( 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); @@ -266,6 +293,8 @@ fn tab_cell<'a, M: Clone + 'a>( // 底层:内容自己决定宽度(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, @@ -274,8 +303,13 @@ fn tab_cell<'a, M: Clone + 'a>( left: geometry.pad, }); - let bar = bar_color(ui, primary, state, selected); - let radius = geometry.bar_radius(primary); + // 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 的宽(不是整行) diff --git a/crates/app-iced/src/components/navigation/tabs/tests.rs b/crates/app-iced/src/components/navigation/tabs/tests.rs index f0e6a16..0446961 100644 --- a/crates/app-iced/src/components/navigation/tabs/tests.rs +++ b/crates/app-iced/src/components/navigation/tabs/tests.rs @@ -7,6 +7,8 @@ use unlucky_design::typography::Platform; use crate::theme::Families; +use super::super::super::interaction; + fn ui() -> Ui { Ui::with_platform( SchemeFlavor::Light, @@ -62,23 +64,34 @@ fn inactive_states_lift_to_on_surface() { } } -/// 6.2 / 6.6:Primary 只有选中项有 3px `Primary` 条;Secondary 每项都有 1px `SurfaceVariant`。 +/// 6.2:Primary 只有选中项有 3px `Primary` 条;禁用项降档。 #[test] -fn bars_follow_the_variant() { +fn primary_bar_only_on_the_selected_tab() { let ui = ui(); - assert_eq!(bar_color(&ui, true, Interaction::Enabled, false), None); + assert_eq!(bar_color(&ui, Interaction::Enabled, false), None); assert_eq!( - bar_color(&ui, true, Interaction::Enabled, true), + bar_color(&ui, Interaction::Enabled, true), Some(ui.color(Role::Primary)) ); assert_eq!( - bar_color(&ui, false, Interaction::Enabled, false), - Some(ui.color(Role::SurfaceVariant)), - "6.6:分隔线是 SurfaceVariant" + 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!( - bar_color(&ui, false, Interaction::Enabled, false), - Some(ui.color(Role::OutlineVariant)), + secondary_line_color(&ui), + ui.color(Role::OutlineVariant), "OutlineVariant 是 §22 分隔线的角色,Tabs 不用它" ); }