diff --git a/crates/app-iced/src/shell/nav.rs b/crates/app-iced/src/shell/nav.rs index 3705950..517c1fe 100644 --- a/crates/app-iced/src/shell/nav.rs +++ b/crates/app-iced/src/shell/nav.rs @@ -13,9 +13,11 @@ //! | 项间距 | 5.7 **4** | `LayoutMetrics::nav_item_gap` | use iced::widget::{button, column, container}; -use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme}; +use iced::{ + Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, +}; -use unlucky_design::color::{Role, StateLayer}; +use unlucky_design::color::Role; use unlucky_design::elevation::MaterialLayer; use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; @@ -29,6 +31,7 @@ pub use account::RailAccount; use account::account_item; +use crate::app::components::interaction::{self, Interaction}; use crate::app::Message; use crate::cli::ForcedState; use crate::icons; @@ -112,7 +115,7 @@ pub(super) fn rail<'a>( .iter() // `ALL` 本来就不含 `Account`(它固定在轨底),filter 只是把这条意图写明。 .filter(|item| **item != NavItem::Account) - .map(|item| rail_item(ui, *item, *item == selected, indicator, compact, forced, lang)) + .map(|item| rail_item(ui, *item, *item == selected, compact, forced, lang)) .collect::>(); // 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的 @@ -141,12 +144,15 @@ fn rail_item<'a>( ui: &'a Ui, item: NavItem, selected: bool, - indicator: (f32, f32), compact: bool, forced: Option, lang: Lang, ) -> Element<'a, Message> { - let (indicator_width, indicator_height) = indicator; + // 状态层(hover / pressed / 过渡帧)落在**药丸**上,与选中指示器同一块几何(M283)。 + let (pill_width, pill_height) = state_layer_box(ui.metrics()); + // 取证态与运行期态是**同一个** `Interaction`:强制态由 `overridden` 直接推(`None` → `Active` + // → `Enabled`),运行期的真态由 `interaction::tracked` 的取样层在 draw 里给。 + let state = Interaction::from_status(overridden(forced, button::Status::Active)); let icon_color = if selected { ui.color(Role::OnSecondaryContainer) @@ -164,25 +170,23 @@ fn rail_item<'a>( // 于是 56 被父级(button 宽 88)覆盖成 88、32 靠布局凑巧(视觉复审 P2-1)。 // 尺寸只认令牌推导值,居中交给父 column 的 `align_x`。 let pill = container(icons::view::(item.icon(), icon_color)) - .width(indicator_width) - .height(indicator_height) + .width(pill_width) + .height(pill_height) .align_x(Alignment::Center) .align_y(Alignment::Center) .style(move |_theme| container::Style { - background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))), + // 状态层画在**药丸**上(M283):hover / pressed / 过渡帧都只这一块 56×32。 + // 之前它摊在 88×52 的按钮上(`Border::default()` ⇒ r=0),同一个元素并存两种形状。 + background: pill_color(ui, state, selected).map(Background::Color), border: Border { - radius: ui.radius(ShapeScale::Full, indicator_height), + radius: ui.radius(ShapeScale::Full, pill_height), ..Border::default() }, ..container::Style::default() }); - let item_height = nav_item_height(ui, indicator_height); - let height = if compact { - indicator_height - } else { - item_height - }; + let item_height = nav_item_height(ui, pill_height); + let height = if compact { pill_height } else { item_height }; // 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作, // 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。 let mut stack = column![pill] @@ -201,35 +205,26 @@ fn rail_item<'a>( } stack = stack.spacing(ui.space(Space::X1)); - button(stack) + let clickable: Element<'a, Message> = button(stack) .width(nav_rail_width(ui.metrics())) .height(height) .padding(0) - .style(move |_theme: &IcedTheme, status| { - let background = match overridden(forced, status) { - button::Status::Hovered if !selected => Some(Background::Color(ui.state_over( - StateLayer::Hover, - Role::OnSurface, - Role::Surface, - ))), - button::Status::Pressed if !selected => Some(Background::Color(ui.state_over( - StateLayer::Pressed, - Role::OnSurface, - Role::Surface, - ))), - _ => None, - }; - - button::Style { - background, - text_color: label_color, - border: Border::default(), - shadow: Shadow::default(), - snap: true, - } + .style(move |_theme: &IcedTheme, _status| button::Style { + // 底色**恒为 `None`**:状态层由药丸给(M283,同一个元素不许并存两种形状)。 + background: None, + text_color: label_color, + border: Border::default(), + shadow: Shadow::default(), + snap: true, }) .on_press(Message::Select(item)) - .into() + .into(); + // A8:运行期态套状态取样控件(强制态 = 取证,**不套**:取证图不许有过渡中间帧)。 + if forced.is_none() { + interaction::tracked(clickable, true) + } else { + clickable + } } /// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。 @@ -258,6 +253,41 @@ pub fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) { ) } +/// 状态层(hover / pressed / 过渡帧)画在**哪块矩形**上:药丸,与选中指示器同一块几何(M283)。 +/// +/// 这是那块矩形的唯一定义点——`rail_item` 用它定尺寸,`shell::tests` 的几何判据钉住它。 +/// 它**不是**按钮的 `nav_rail_width × 项高`(88×52):那正是用户点名的「父容器整个方形区域加深」。 +pub(super) fn state_layer_box(metrics: LayoutMetrics) -> (f32, f32) { + indicator_size(metrics) +} + +/// 药丸底色(**唯一**实现点):hover / pressed / 过渡帧都画在这块 56×32 上。 +/// +/// 选中/未选中**两条分支不许合并**(照 `components/navigation/drawer/item.rs` 的 `pill_color`): +/// 选中项静息是实色 `SecondaryContainer`,缺层时兜底到那个角色;未选中项静息不画药丸, +/// 兜底就必须是 `None`。经 [`interaction::layer_color`] 求值 ⇒ 没有取样控件时**逐位**等于原来的 +/// `state_over(..)`(`--force-state=rest` 的取证图因此一字不变)。 +fn pill_color(ui: &Ui, state: Interaction, selected: bool) -> Option { + if selected { + interaction::layer_color( + ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + ) + .or_else(|| Some(ui.color(Role::SecondaryContainer))) + } else { + // 导航轨自己画的就是 `MaterialLayer::Canvas`(见 [`rail`] 的 `.style(..)`):状态层要叠在 + // **同一个**角色上,否则 hover 色与轨底色对不上。 + interaction::layer_color( + ui, + state, + Role::OnSurface, + MaterialLayer::Canvas.surface_role(), + ) + } +} + /// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏, /// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。 pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 { diff --git a/crates/app-iced/src/shell/nav/account.rs b/crates/app-iced/src/shell/nav/account.rs index f3fa2df..82b838c 100644 --- a/crates/app-iced/src/shell/nav/account.rs +++ b/crates/app-iced/src/shell/nav/account.rs @@ -4,14 +4,16 @@ //! (未登录/已登录两态、窄高收成图标条的判据、点击跳账户页)。 use iced::widget::{button, column, container, text}; -use iced::{Alignment, Background, Border, Element, Length, Shadow, Theme as IcedTheme}; +use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme as IcedTheme}; -use unlucky_design::color::{Role, StateLayer}; +use unlucky_design::color::Role; +use unlucky_design::elevation::MaterialLayer; use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; +use crate::app::components::interaction::{self, Interaction}; use crate::app::Message; use crate::cli::ForcedState; use crate::icons; @@ -75,6 +77,9 @@ pub(super) fn account_item<'a>( ) -> Element<'a, Message> { let (indicator_width, indicator_height) = indicator; let active = selected == NavItem::Account; + // 取证态与运行期态是**同一个** `Interaction`:强制态由 `overridden` 直接推(`None` → `Active` + // → `Enabled`),运行期的真态由 `interaction::tracked` 的取样层在 draw 里给。 + let state = Interaction::from_status(overridden(forced, button::Status::Active)); let label_color = if active { ui.color(Role::Secondary) } else { @@ -97,7 +102,8 @@ pub(super) fn account_item<'a>( .align_x(Alignment::Center) .align_y(Alignment::Center) .style(move |_theme| container::Style { - background: Some(Background::Color(ui.color(Role::SecondaryContainer))), + // 状态层画在**头像这个圆**上(M283),不再摊到 88×52 的按钮上。 + background: avatar_color(ui, state, true).map(Background::Color), border: Border { radius: ui.radius(ShapeScale::Full, avatar_size(ui, indicator_width)), ..Border::default() @@ -115,6 +121,8 @@ pub(super) fn account_item<'a>( .align_x(Alignment::Center) .align_y(Alignment::Center) .style(move |_theme| container::Style { + // 未登录的头像静息只有描边:状态层照样落在**这个圆**里(M283)。 + background: avatar_color(ui, state, false).map(Background::Color), border: Border { color: ui.color(Role::Outline), width: 1.0, @@ -145,31 +153,51 @@ pub(super) fn account_item<'a>( ); } - button(stack) + let clickable: Element<'a, Message> = button(stack) .width(nav_rail_width(ui.metrics())) .height(height) .padding(0) - .style(move |_theme: &IcedTheme, status| button::Style { - background: match overridden(forced, status) { - button::Status::Hovered if !active => Some(Background::Color(ui.state_over( - StateLayer::Hover, - Role::OnSurface, - Role::Surface, - ))), - button::Status::Pressed if !active => Some(Background::Color(ui.state_over( - StateLayer::Pressed, - Role::OnSurface, - Role::Surface, - ))), - _ => None, - }, + .style(move |_theme: &IcedTheme, _status| button::Style { + // 底色**恒为 `None`**:状态层由头像圆给(M283,同一个元素不许并存两种形状)。 + background: None, text_color: label_color, border: Border::default(), shadow: Shadow::default(), snap: true, }) .on_press(Message::Select(NavItem::Account)) - .into() + .into(); + // A8:运行期态套状态取样控件(强制态 = 取证,**不套**:取证图不许有过渡中间帧)。 + if forced.is_none() { + interaction::tracked(clickable, true) + } else { + clickable + } +} + +/// 头像圆的底色(**唯一**实现点):hover / pressed / 过渡帧都画在这个 Ø40 圆上。 +/// +/// 两条分支不许合并(照 `drawer/item.rs` 的 `pill_color`):已登录的头像静息是实色 +/// `SecondaryContainer`,缺层时兜底到那个角色;未登录的头像静息只有描边、不填底, +/// 兜底就必须是 `None`。 +fn avatar_color(ui: &Ui, state: Interaction, signed_in: bool) -> Option { + if signed_in { + interaction::layer_color( + ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + ) + .or_else(|| Some(ui.color(Role::SecondaryContainer))) + } else { + // 头像直接摆在导航轨上(轨画的是 `MaterialLayer::Canvas`):状态层要叠在同一个角色上。 + interaction::layer_color( + ui, + state, + Role::OnSurface, + MaterialLayer::Canvas.surface_role(), + ) + } } /// 头像位边长 = 指示器宽 − 16,但不超过 48(12b 里没有这个控件,按令牌栅格取整)。 diff --git a/crates/app-iced/src/shell/tests.rs b/crates/app-iced/src/shell/tests.rs index 0aa7b6d..4a8dd75 100644 --- a/crates/app-iced/src/shell/tests.rs +++ b/crates/app-iced/src/shell/tests.rs @@ -129,3 +129,123 @@ fn caption_icon_shrinks_without_touching_the_container() { "caption 图标必须小于官方 24(缩小是用户点名的)" ); } + +/// **M283 几何判据**:状态层(hover / pressed)画的矩形 == 药丸 [`nav::indicator_size`](56×32), +/// **不是**按钮的轨宽 × 项高(88×52)。 +/// +/// 抓得住:谁把状态层改回「铺满整条轨宽」(旧的 `button(stack).style(..)` 铺底写法)——那条路 +/// 下这块矩形就是 88×52,两个 `assert_ne` 立刻红。 +/// 抓不住:真画出来的像素(那要出图,见 `DevDocs/artifacts/navpill/`);也抓不住颜色对不对 +/// (那归 `interaction` 的 `layer_color` 单测)。 +#[test] +fn the_state_layer_is_drawn_on_the_pill_not_across_the_rail() { + use unlucky_design::shape::ShapeScale; + + use super::nav::{indicator_size, nav_item_height, state_layer_box}; + + let ui = ui(SchemeFlavor::Light); + let metrics = ui.metrics(); + let pill = state_layer_box(metrics); + + assert_eq!( + pill, + indicator_size(metrics), + "状态层与选中指示器必须是同一块矩形" + ); + assert_eq!(pill, (56.0, 32.0), "12b 5.4 的指示器尺寸"); + assert_ne!( + pill.0, metrics.nav_rail_width, + "状态层不许铺满轨宽({})——那正是用户点名的方形加深", + metrics.nav_rail_width + ); + assert_ne!( + pill, + (metrics.nav_rail_width, nav_item_height(&ui, pill.1)), + "状态层也不许铺满整个按钮(轨宽 × 项高 = 88×52)" + ); + + let radius = ui.radius(ShapeScale::Full, pill.1); + assert_eq!( + (radius.top_left, radius.bottom_right), + (pill.1 / 2.0, pill.1 / 2.0), + "Full ⇒ r=16:药丸四角必须是空的,不能有 hover 色" + ); +} + +/// **M283 结构判据**:状态层不许回到按钮上(扫源码,不渲染)。 +/// +/// `button(stack).style(..)` 的闭包里出现 `Background::Color` 就是「同一个元素并存两种形状」 +/// 回来了——选中画 56×32 圆角药丸、hover 却铺 88×52 直角底(用户原话:父容器整个方形区域加深)。 +/// +/// 抓得住:①有人把 `button::Status` 分支接回按钮的 `style`;②`interaction::tracked` 被套到 +/// 强制态上(取证图会出现过渡中间帧);③药丸尺寸改回按钮尺寸(`state_layer_box` 的调用点消失)。 +/// 抓不住:颜色对不对、真画出来是圆的还是方的——前者归 `layer_color` 的单测,后者归出图。 +#[test] +fn the_rail_never_paints_its_state_layer_on_the_button() { + let sources = [ + ("shell/nav.rs", include_str!("nav.rs")), + ("shell/nav/account.rs", include_str!("nav/account.rs")), + ]; + + for (name, source) in sources { + let mut buttons = 0; + for (start, _) in source.match_indices("button(stack)") { + let rest = &source[start..]; + let end = rest + .find(".on_press(") + .unwrap_or_else(|| panic!("{name}:`button(stack)` 之后找不到 `.on_press(`")); + let builder = &rest[..end]; + assert!( + !builder.contains("Background::Color"), + "{name}:按钮的 style 闭包里又铺底了 —— 状态层必须落在药丸/头像上" + ); + assert!( + builder.contains("background: None"), + "{name}:按钮的 `background` 必须恒为 `None`" + ); + buttons += 1; + } + assert_eq!(buttons, 1, "{name}:期望正好一处 `button(stack)`"); + + // `tracked` 只许在 `forced.is_none()` 时套(强制态 = 取证,不许有过渡中间帧) + let lines = source.lines().collect::>(); + let mut tracked = 0; + for (index, line) in lines.iter().enumerate() { + if !line.contains("interaction::tracked(") { + continue; + } + tracked += 1; + let guard = index.saturating_sub(2); + assert!( + lines[guard..index] + .iter() + .any(|line| line.contains("forced.is_none()")), + "{name}:{}:`tracked` 必须只在 `forced.is_none()` 时套(取证图不许有过渡)", + index + 1 + ); + } + assert_eq!(tracked, 1, "{name}:期望正好一处 `interaction::tracked(`"); + } + + // 另一半:药丸/头像那一侧必须真的经 `layer_color` 求值,且药丸尺寸取自几何定义点 + let nav = include_str!("nav.rs"); + let account = include_str!("nav/account.rs"); + assert!( + nav.contains("pill_color(ui, state, selected)"), + "nav.rs:药丸底色必须经 pill_color 求值" + ); + assert!( + account.contains("avatar_color(ui, state,"), + "account.rs:头像底色必须经 avatar_color 求值" + ); + for (name, source) in [("nav.rs", nav), ("account.rs", account)] { + assert!( + source.contains("interaction::layer_color"), + "{name}:状态层必须经 layer_color 求值(没有取样控件时逐位退回 overlay_color)" + ); + } + assert!( + nav.contains("state_layer_box(ui.metrics())"), + "nav.rs:药丸尺寸必须取自 `state_layer_box`(几何判据钉的就是它)" + ); +}