diff --git a/crates/app-iced/src/components/buttons/assemble.rs b/crates/app-iced/src/components/buttons/assemble.rs index 406b18b..eeae37c 100644 --- a/crates/app-iced/src/components/buttons/assemble.rs +++ b/crates/app-iced/src/components/buttons/assemble.rs @@ -13,7 +13,7 @@ use unlucky_design::shape::ShapeScale; use unlucky_design::typography::TypeRole; use crate::icons; -use crate::theme::{Ui, STROKE_PX}; +use crate::theme::Ui; use super::super::interaction::{self, Interaction}; use super::super::layout; @@ -30,6 +30,8 @@ pub(super) struct StyleSpec { pub border: Option, /// 圆角档位(来自组件表,五式统一 `Full`)。 pub shape: ShapeScale, + /// 描边宽度(`Geometry::stroke`:有描边的变体 = `stroke::HAIRLINE`,其余 0)。 + pub stroke: f32, /// 圆角折算用的边长(药丸 = 可见高)。 pub radius_side: f32, /// 强制态(gallery 取证);`None` = 跟随运行期的 `button::Status`。 @@ -48,6 +50,11 @@ pub(super) fn assemble<'a, M: Clone + 'a>( ) -> Element<'a, M> { let geometry = Geometry::of(ui, variant, spec.leading.is_some()); let (fill, content, border) = variant.roles(); + let forced = spec.state.or(if spec.on_press.is_none() { + Some(Interaction::Disabled) + } else { + None + }); // 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影) debug_assert!( @@ -57,7 +64,11 @@ pub(super) fn assemble<'a, M: Clone + 'a>( ), "按钮家族不许有浮层高程" ); - let content_color = ui.color(content); + // **内容色必须跟着态走**:禁用时文字/图标是 on-surface@38%,不是满档内容色。 + // 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color` + // 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格 + // 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。 + let ink = content_color(ui, forced, content); let label = text(spec.label) .size(ui.style(TypeRole::LabelLarge).size_px) @@ -65,7 +76,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>( ui.style(TypeRole::LabelLarge).line_height_px, ))) .font(ui.font_of(ui.emphasized(TypeRole::LabelLarge))) - .color(content_color); + .color(ink); let mut row = Row::new().align_y(Alignment::Center); if let Some(kind) = spec.leading { @@ -73,7 +84,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>( row = row.push(layout::fixed( geometry.icon, geometry.icon, - icons::view::(kind, content_color), + icons::view::(kind, ink), )); } row = row.push(label); @@ -81,7 +92,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>( row = row.push(layout::fixed( geometry.icon, geometry.icon, - icons::view::(kind, content_color), + icons::view::(kind, ink), )); } let row = row.spacing(geometry.gap); @@ -93,12 +104,6 @@ pub(super) fn assemble<'a, M: Clone + 'a>( .height(Length::Fill) .align_y(iced::Alignment::Center); - let forced = spec.state.or(if spec.on_press.is_none() { - Some(Interaction::Disabled) - } else { - None - }); - let style = style_closure( ui, StyleSpec { @@ -106,6 +111,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>( content, border, shape: variant.shape(), + stroke: geometry.stroke, radius_side: geometry.height, forced, }, @@ -122,6 +128,17 @@ pub(super) fn assemble<'a, M: Clone + 'a>( } } +/// 内容色(文字与图标共用):禁用态走令牌的 38%,其余态就是角色色。 +/// +/// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon` +/// (图标按钮)都调它,两条路径不会再各自钉死满档色。 +pub(super) fn content_color(ui: &Ui, forced: Option, content: Role) -> iced::Color { + match forced { + Some(Interaction::Disabled) => interaction::disabled_colors(ui, Role::Surface).1, + _ => ui.color(content), + } +} + /// 生成 `button::style` 闭包:状态层 / 禁用 / 描边全在这里落地。 pub(super) fn style_closure( ui: &Ui, @@ -166,15 +183,16 @@ pub(super) fn style_closure( } else { iced::Color::TRANSPARENT }, - width: if spec.border.is_some() { - STROKE_PX - } else { - 0.0 - }, + // 线宽来自 `Geometry::stroke`(有描边的变体 = HAIRLINE,其余 0); + // `spec.border.is_none()` 时颜色是透明的,宽度也就无所谓了。 + width: spec.stroke, radius: ui.radius(spec.shape, spec.radius_side), }, // rubric §7:L0/L1 不用阴影(Elevated 的 L1 由容器色调差表达) shadow: Shadow::default(), + // ⚠️ `snap` 目前**只有 wgpu 后端实现**;我们的验收图走 tiny-skia(CPU), + // 所以这个 `true` 在验收图上不起作用——分隔线的「1dp 摊成 2 行」正是这个原因, + // 不是「iced 没有 snap」(reviewer P2-4 纠正的正是我上一版报告里的说法)。 snap: true, } } diff --git a/crates/app-iced/src/components/buttons/geometry.rs b/crates/app-iced/src/components/buttons/geometry.rs index 382d31f..30cbc88 100644 --- a/crates/app-iced/src/components/buttons/geometry.rs +++ b/crates/app-iced/src/components/buttons/geometry.rs @@ -82,12 +82,15 @@ pub struct IconGeometry { } impl IconGeometry { - /// 从令牌求值。 + /// 从令牌求值。图标边长**由 `side − 2×padding` 推导**(40 − 16 = 24), + /// 与 `IconSize::Toolbar`(24)互为校验:两者不等说明令牌层动了,单测会先红。 pub fn of(ui: &Ui) -> Self { + let side = ui.metrics().control_height; + let padding = ui.space(Space::X2); Self { - side: ui.metrics().control_height, - icon: IconSize::Toolbar.px(), - padding: ui.space(Space::X2), + side, + icon: side - 2.0 * padding, + padding, } } } diff --git a/crates/app-iced/src/components/buttons/mod.rs b/crates/app-iced/src/components/buttons/mod.rs index 31d4587..1d6d1fe 100644 --- a/crates/app-iced/src/components/buttons/mod.rs +++ b/crates/app-iced/src/components/buttons/mod.rs @@ -289,11 +289,10 @@ pub fn icon<'a, M: Clone + 'a>( // 撑满内容区(M68:24 的字形渲染成 46px、线宽 1.5 → 2.9)。 // 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中, // 所以「居中」这件事必须由容器自己写死,别指望 button。 - let icon_box = layout::centered_fixed( - geometry.side, - geometry.side, - icons::view::(kind, ui.color(content)), - ); + // 内容色跟态走(禁用 = on-surface@38%),见 `assemble::content_color` 的说明 + let glyph = assemble::content_color(ui, forced, content); + let icon_box = + layout::centered_fixed(geometry.side, geometry.side, icons::view::(kind, glyph)); let style = style_closure( ui, @@ -303,6 +302,7 @@ pub fn icon<'a, M: Clone + 'a>( border, // 方形图标按钮同样是 full 圆角(40 → 20),档位取自组件表 shape: Component::IconButton.spec().shape, + stroke: 0.0, radius_side: geometry.side, forced, }, diff --git a/crates/app-iced/src/components/buttons/tests.rs b/crates/app-iced/src/components/buttons/tests.rs index 998075b..5aeea92 100644 --- a/crates/app-iced/src/components/buttons/tests.rs +++ b/crates/app-iced/src/components/buttons/tests.rs @@ -219,6 +219,7 @@ fn spec_of(variant: Variant, forced: Option) -> StyleSpec { content, border, shape: variant.shape(), + stroke: Geometry::of(&light(), variant, false).stroke, radius_side: Geometry::of(&light(), variant, false).height, forced, } diff --git a/crates/app-iced/src/components/gallery/buttons.rs b/crates/app-iced/src/components/gallery/buttons.rs index 9ffec2e..6c347d1 100644 --- a/crates/app-iced/src/components/gallery/buttons.rs +++ b/crates/app-iced/src/components/gallery/buttons.rs @@ -131,21 +131,20 @@ fn text_button<'a>(ui: &'a Ui, variant: Variant, state: Interaction) -> Element< Variant::Text => "Text", }; - let lead = if variant == Variant::Text { - Button::new(label, DemoMessage::Pressed("text")) - } else { - Button::new(label, DemoMessage::Pressed(label)) - .leading(IconKind::Play) - .trailing(IconKind::ChevronRight) - }; - - // 禁用格必须真的没有消息(语义正确),再强制成 Disabled 的画法 - let spec = if state.is_disabled() { + // 五态共用**同一套**装配,只有「有没有消息」随禁用变: + // 早先禁用格单独走 `Button::disabled(label)`,把前后图标一起丢了,于是 disabled 列看起来 + // 像另一个组件(视觉 reviewer P2-3)。 + let mut spec = if state.is_disabled() { Button::disabled(label) } else { - lead + Button::new(label, DemoMessage::Pressed(label)) } .state(state); + if variant != Variant::Text { + spec = spec + .leading(IconKind::Play) + .trailing(IconKind::ChevronRight); + } match variant { Variant::Filled => buttons::filled(ui, spec), diff --git a/crates/app-iced/src/components/gallery/mod.rs b/crates/app-iced/src/components/gallery/mod.rs index 8efb695..d22e340 100644 --- a/crates/app-iced/src/components/gallery/mod.rs +++ b/crates/app-iced/src/components/gallery/mod.rs @@ -188,9 +188,13 @@ pub(super) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> { .collect() } -/// 矩阵单元格宽度(只影响 harness 的取景,不是组件尺寸;216 = 5 列正好铺满 1092 内容宽)。 +/// 矩阵单元格宽度(只影响 harness 的取景,不是组件尺寸)。 +/// +/// 160 = `Space::X8 × 5`:5 列 + 1 个标签列 + 4 个列间距 + 页左右各 24 +/// = 6×160 + 4×8 + 48 = **1040 < 1180**,所以第 5 列(disabled)不再被窗口右缘裁掉。 +/// 早先是 192(×6),总宽 1232 撑破窗口,disabled 列被裁掉一半(视觉 reviewer P2-3)。 pub(super) fn cell_width(ui: &Ui) -> f32 { - ui.space(Space::X8) * 6.0 + ui.space(Space::X8) * 5.0 } /// 一个矩阵行的左侧标签(变体名)。 diff --git a/crates/app-iced/src/components/gallery/probe.rs b/crates/app-iced/src/components/gallery/probe.rs index c1fd75d..6cb6121 100644 --- a/crates/app-iced/src/components/gallery/probe.rs +++ b/crates/app-iced/src/components/gallery/probe.rs @@ -35,6 +35,50 @@ fn ui(flavor: SchemeFlavor) -> Ui { } /// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。 +/// 一条 1dp 细线在竖直方向的「墨量覆盖率」:各行相对底色的不透明度之和(1dp ⇒ ≈1.0)。 +fn line_coverage(shot: &Shot, surface: [u8; 3], line: [u8; 3]) -> f32 { + let mut total = 0.0; + for y in 0..shot.height { + let mut sum = 0.0f32; + let mut count = 0usize; + for x in 0..shot.width { + let pixel = shot.at(x, y); + if !matches(pixel, surface) { + count += 1; + sum += delta_ratio(pixel, surface, line); + } + } + if count > 300 { + total += sum / count as f32; + } + } + total +} + +/// `pixel` 相对 `surface` 的偏移占「满档 line 色偏移」的比例(0..=1)。 +fn delta_ratio(pixel: [u8; 3], surface: [u8; 3], line: [u8; 3]) -> f32 { + let channel = |i: usize| { + let full = f32::from(line[i]) - f32::from(surface[i]); + if full.abs() < 1.0 { + return 0.0; + } + ((f32::from(pixel[i]) - f32::from(surface[i])) / full).clamp(0.0, 1.0) + }; + let (dr, dg, db) = (channel(0), channel(1), channel(2)); + (dr + dg + db) / 3.0 +} + +/// 图上某个精确色的像素数(带容差)。 +fn count_pixels(shot: &Shot, color: [u8; 3]) -> usize { + (0..shot.height) + .map(|y| { + (0..shot.width) + .filter(|x| matches(shot.at(*x, y), color)) + .count() + }) + .sum() +} + fn shot_path() -> Option { let raw = std::env::var_os("ULL_GALLERY_SHOT")?; let path = std::path::PathBuf::from(raw); @@ -138,7 +182,33 @@ fn gallery_geometry_matches_the_tokens() { "药丸圆角应 = 20(12b 1.2):顶部行左内缩应 ≈14,实测 {top_inset}(8 圆角 ≈4,直角 0)" ); - // ---- Q9:IconButton 的字形不许撑满 40 盒 --- // ---- Q9:IconButton 的字形不许撑满 40 盒 ----------------------------------- + // ---- 12b 1.4 / 1.6:内边距 16、图标↔文字 8(用带前置图标的 Filled 格量)---- + let ink = shot.ink_inside(&label_button, [255, 255, 255]); + if let Some(ink) = ink { + let icon_pad = ink.left - label_button.left; + // 图标盒左缘 = 16;字形在 24 viewBox 里自带 ≈5.3 的左边距 ⇒ 墨迹落在 21 附近 + assert!( + (19..=23).contains(&icon_pad), + "12b 1.4:带图标左内边距应 16(墨迹 ≈21),实测 {icon_pad}" + ); + } else { + panic!("Filled 格应带前置图标(量内边距用)"); + } + + // ---- Q9:IconButton 的字形不许撑满 40 盒 --- // ---- 12b 1.4 / 1.6:内边距 16、图标↔文字 8(用带前置图标的 Filled 格量)---- + let ink = shot.ink_inside(&label_button, [255, 255, 255]); + if let Some(ink) = ink { + let icon_pad = ink.left - label_button.left; + // 图标盒左缘 = 16;字形在 24 viewBox 里自带 ≈5.3 的左边距 ⇒ 墨迹落在 21 附近 + assert!( + (19..=23).contains(&icon_pad), + "12b 1.4:带图标左内边距应 16(墨迹 ≈21),实测 {icon_pad}" + ); + } else { + panic!("Filled 格应带前置图标(量内边距用)"); + } + + // ---- Q9:IconButton 的字形不许撑满 40 盒 ----------------------------------- let tonal = rgb(ui.color(Role::SecondaryContainer)); let tonal_boxes = shot.bbox_of(tonal); let icon_states = tonal_boxes @@ -191,10 +261,18 @@ fn gallery_geometry_matches_the_tokens() { runs += 1; } } - // 两条分隔线 × 各摊在 1~2 行上(抗锯齿)⇒ 2..=6 行;>6 说明线粗了或画了第三条 + // 两条线 × 各摊 1~2 行 ⇒ 2..=4 行 assert!( - (2..=6).contains(&runs), - "分隔线应 2 条 × 1px(抗锯齿时各占 2 行),实测 {runs} 行" + (2..=4).contains(&runs), + "分隔线应 2 条(每条 1px,抗锯齿最多摊 2 行)⇒ 2..=4 行,实测 {runs} 行" + ); + // **墨量覆盖率**:一条 1dp 的线,其各行相对底色的不透明覆盖率之和应 ≈1.0。 + // 1.5dp 的线会到 ≈1.5,2dp 会到 ≈2.0 —— 这是「容忍 1.5 倍粗」的补丁。 + let full = rgb(ui.color(Role::OutlineVariant)); + let coverage = line_coverage(&shot, surface, full); + assert!( + (0.8..=1.3).contains(&coverage), + "分隔线墨量覆盖率应 ≈1.0(= 1dp),实测 {coverage:.2}(1.5dp 会到 ~1.5)" ); // ---- 12b 1.8:禁用格必须与静息格不同 ------------------------------------ @@ -207,20 +285,47 @@ fn gallery_geometry_matches_the_tokens() { "没找到禁用态实心按钮(12b 1.8:容器 = on-surface@12%)" ); + // ---- 12b 1.8(视觉 reviewer 的 P1-A):禁用态的内容色必须真的下发 ---- + let (disabled_container, disabled_ink) = + crate::app::components::interaction::disabled_colors(&ui, Role::Surface); + let ink_px = count_pixels(&shot, rgb(disabled_ink)); + assert!( + ink_px > 200, + "禁用内容色(on-surface@38%)在图上只出现 {ink_px} 像素: 说明 text / icon 的字形没走 38%(旧实现 enabled 与 disabled 逐像素相同)" + ); + + // filled 禁用容器里不许出现满档 OnPrimary(旧实现是 #FFFFFF 压在 12% 容器上 = 1.23:1) + let full_content = rgb(ui.color(Role::OnPrimary)); + for bbox in shot.bbox_of(rgb(disabled_container)) { + if bbox.width() < 60 { + continue; + } + for y in (bbox.top + 2)..=(bbox.bottom - 2) { + for x in (bbox.left + 2)..=(bbox.right - 2) { + assert!( + !matches(shot.at(x, y), full_content), + "禁用容器内出现满档 OnPrimary @({x},{y}):禁用标签没降到 38%" + ); + } + } + } + eprintln!( "[probe] OK flavor={:?}: Filled 药丸 {}×{}(参考 高 40 / 宽 ≥58) 顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0) 实心块 {} 个(含 40×40 IconButton) 分隔线长行 {} 行(每条 1px) - 禁用容器 (on-surface@12%) 命中 + 禁用容器 (on-surface@12%) 命中 · 禁用内容 38% 墨迹 {} px + 分隔线墨量覆盖率 {:.2}(1dp 应 ≈1.0) 字形墨迹 {}px(24 盒应 16~26)", flavor, label_button.width(), true_height, top_inset, primary.len(), - runs, + ink_px, + coverage, glyph_width ); } diff --git a/crates/app-iced/src/components/gate.rs b/crates/app-iced/src/components/gate.rs index 80da463..223ba69 100644 --- a/crates/app-iced/src/components/gate.rs +++ b/crates/app-iced/src/components/gate.rs @@ -211,36 +211,33 @@ fn every_component_file_stays_under_the_line_limit() { ); } -/// 四个家族桩必须**只有桩**(不许顺手把别人的组件写了)。 +/// 四个家族目录必须存在且结构完整。 /// -/// 判据:文件里 `pub fn` 只允许出现一次,且它是 `demo`。 +/// **这里刻意不再限制「pub fn 有几个」**:M76 往 `selection/` 里加 Checkbox/Radio 是它的本职工作, +/// 而家族线被明确要求不许改 `gate.rs`——把「桩还在不在」写成 pub fn 计数,等于给 M76 埋一个 +/// 它无权拆的地雷(reviewer P1-C)。越界与否由 merge gate 的 scope 判定(各家族目录只归对应 +/// mission),门禁只管「地基还在」: +/// +/// - 四个家族目录各有 `mod.rs`; +/// - `components/mod.rs` 仍声明这五个家族 + gallery(谁把声明删了,这里先红)。 #[test] -fn the_four_family_stubs_are_still_stubs() { +fn the_five_families_are_declared_and_present() { + let declared = + std::fs::read_to_string(components_dir().join("mod.rs")).expect("读 components/mod.rs"); + for family in ["selection", "inputs", "feedback", "navigation"] { - let path = components_dir().join(family).join("mod.rs"); - let text = std::fs::read_to_string(&path) - .unwrap_or_else(|error| panic!("家族桩 {} 不见了:{error}", path.display())); - let product = product_part(&text); - - let pub_fns: Vec<&str> = product - .lines() - .filter(|line| line.trim_start().starts_with("pub fn ")) - .collect(); - - assert_eq!( - pub_fns.len(), - 1, - "{} 只该有占位 demo(),实际有 {} 个 pub fn:{pub_fns:?}(别顺手实现别人的家族)", - path.display(), - pub_fns.len() - ); + let dir = components_dir().join(family); + let file = dir.join("mod.rs"); + assert!(file.is_file(), "家族目录 {} 缺 mod.rs", dir.display()); assert!( - pub_fns[0].contains("fn demo"), - "{} 里唯一那个 pub fn 应该是 demo,实际是 {:?}", - path.display(), - pub_fns[0] + declared.contains(&format!("pub mod {family};")), + "components/mod.rs 少了 `pub mod {family};` 声明" ); } + assert!( + declared.contains("pub mod gallery;") && declared.contains("pub mod buttons;"), + "components/mod.rs 的声明被动了" + ); } /// gallery 的五个家族文件必须都在(`mod.rs` 声明了它们,缺一个就编译不过——