From a1adf024ba398582b110c0ed36f29e7e95da6daf Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 17:46:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20M68=20r1=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20M69=20=E8=BF=81=E7=A7=BB=20+=20=E5=AE=88=E9=97=A8=E8=A1=A5?= =?UTF-8?q?=E6=B4=9E=20+=20=E8=8D=AF=E4=B8=B8=2056=C3=9732=20+=20caption?= =?UTF-8?q?=20=E5=9B=BE=E6=A0=87=2024=20+=20=E4=B8=9C=E4=BE=A7=E7=83=AD?= =?UTF-8?q?=E5=8C=BA=E5=9B=9E=201180?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代码复审 p2-2items / 视觉复审 p2-2items 的修复轮(配色重拍等 M70 定案)。 M69 迁移(2 行) - nav 轨宽:nav_rail_width(metrics) 函数体改用 metrics.nav_rail_width - theme::STROKE_PX 改为 pub use unlucky_design::stroke::HAIRLINE as STROKE_PX P2-1 零硬编码守门补洞(reviewer 造样 5 个漏网样本全部补上,并进自检正向断言) - COLOR 调用表补 from_rgb8 / rgb8 / rgba(`from_rgb(` 子串匹配不到 `from_rgb8(` 是根因) - 新增 `Color { r: .., g: .. }` 结构体字面量检查 - 尺寸规则遇到 Length::Fixed( / Padding::from( / Size::new( 往里剥一层再判数字 - 新增渲染层回归守门 pill_never_overrides_its_size_with_fill(禁 nav.rs 出现 center_*( ): 视觉复审 P2-1 的病根就是 center_*(Length::Fill) 把容器尺寸覆盖成 Fill 视觉 P2-1 药丸 88×32 → 56×32(实测 x=16..71) - 去掉 center_x/center_y(Fill);居中改由父 column 的 align_x + 显式 width(轨宽) 完成 (只去掉 center_* 还不够:column 会 shrink 到 56,药丸贴左缘 x=0..55,是同一个坑的第二层) 视觉 P2-2 / 用户点名 caption 图标 46 → 24(按钮容器保持 46×48) - button 内包 container(...).width(24).height(24).center(Length::Fill): 只设 width/height 会让容器缩到 24 并贴 button 内容左上角;center(Fill) 才既撑满又居中 视觉 P3-1 东侧/南侧缩放热区改成叠层 - 原来是 row 的兄弟列,吃掉 4px 布局宽(右留白 28、关闭键离窗角 4px); 现在用 stack! + 对齐容器叠在工作区上,布局宽回到 1180(卡片右缘 x=1155 = 右留白 24) P3 清单 1. --data-dir 接受并忽略(翻牌前 CI 兼容;现役 smoke 命令行可原样跑,exit 0)+ 单测 2. --diff 内建 ±N px 平移扫描(--diff-scan),CI 不再依赖 numpy 3. 截图模式等 SystemTheme 到达才出图(否则暗色系统上会拍到浅色) 4. (Auto, 未编入 wgpu) 返回 tiny-skia,不再把 wgpu 写进候选列表 5. --renderer=gpu 也跳过适配器枚举(决策与探测无关,省 ~0.6~1.2s) 6. icons::handle 加 debug_assert_eq!(IconKind::ALL[kind as usize], kind) 7. 轨顶留白改 container padding-top,实测 24(原来 spacer + column spacing = 28) 顺带:shot.rs 拆成 shot/mod.rs + shot/diff.rs(≤400 行门禁) 门禁:fmt OK / clippy -D warnings 干净 / 46+5 测试全过 / cargo check --workspace --all-targets OK 体积:双后端 6,769,664 B(6.46 MiB)、仅 CPU 3,955,712 B(3.77 MiB) 截图:r1 代码验证图(r1-verify / hover / pressed / compare-nav-rail)已出;4 张交付主图待 M70 定案后重拍 --- crates/app-iced/src/app.rs | 29 ++- crates/app-iced/src/cli.rs | 80 ++++++ crates/app-iced/src/icons.rs | 8 + crates/app-iced/src/main.rs | 3 +- crates/app-iced/src/probe.rs | 14 +- crates/app-iced/src/shell/mod.rs | 155 +++++++----- crates/app-iced/src/shell/nav.rs | 51 ++-- crates/app-iced/src/{shot.rs => shot/diff.rs} | 236 +++++++++++------- crates/app-iced/src/shot/mod.rs | 100 ++++++++ crates/app-iced/src/theme.rs | 10 +- crates/app-iced/tests/no_hardcoded_tokens.rs | 113 ++++++++- 11 files changed, 599 insertions(+), 200 deletions(-) rename crates/app-iced/src/{shot.rs => shot/diff.rs} (54%) create mode 100644 crates/app-iced/src/shot/mod.rs diff --git a/crates/app-iced/src/app.rs b/crates/app-iced/src/app.rs index 59d15fd..2ded81f 100644 --- a/crates/app-iced/src/app.rs +++ b/crates/app-iced/src/app.rs @@ -58,6 +58,8 @@ pub struct App { nav: NavItem, window: Option, frames: u32, + /// 截图模式的「主题已定」闸门:`system::theme()` 是异步的,早拍会拍到错的深浅色。 + theme_resolved: bool, } /// 消息(单一大枚举;页面接进来时按页拆子枚举)。 @@ -89,6 +91,7 @@ pub enum Message { impl App { fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self { + let options_theme_given = options.shot_flavor().is_some(); Self { ui: Ui::new(flavor, families), families, @@ -96,6 +99,8 @@ impl App { nav: NavItem::Instances, window: None, frames: 0, + // 显式给了主题就算已定;否则等 `SystemTheme` 到达 + theme_resolved: options_theme_given, } } @@ -126,6 +131,7 @@ impl App { }; eprintln!("[theme] 系统主题 = {mode:?} -> {}", slug(flavor)); self.set_flavor(flavor); + self.theme_resolved = true; Task::none() } Message::Frame => self.on_frame(), @@ -171,7 +177,8 @@ impl App { fn on_frame(&mut self) -> Task { self.frames += 1; - if !self.has_screenshot() || self.frames < CAPTURE_AT_FRAME { + // 主题没定就不出图:等 `SystemTheme`(否则暗色系统上会拍到浅色图) + if !self.has_screenshot() || !self.theme_resolved || self.frames < CAPTURE_AT_FRAME { return Task::none(); } @@ -240,6 +247,7 @@ impl App { self.nav.label(), "Skeleton only: titlebar, navigation rail and one card. \ Business pages, the component library and the glass shader are follow-up missions.", + self.options.force_state, ) } } @@ -270,19 +278,24 @@ pub(crate) fn run(options: Options) -> iced::Result { let _ = STARTED.set(Instant::now()); let probe_started = Instant::now(); - // 用户点名要 CPU 时不做探测:枚举 wgpu 适配器在本机要 ~0.85s(实测),纯属白花。 - let probe = if options.backend == crate::Backend::Cpu { - probe::Probe::default() - } else { + // 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关, + // 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。 + let needs_probe = options.backend == crate::Backend::Auto; + let probe = if needs_probe { probe::Probe::run() + } else { + probe::Probe::default() }; let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0; probe::apply(options.backend, &probe); if cfg!(feature = "gpu") { - if options.backend == crate::Backend::Cpu { - eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(--renderer=cpu 跳过枚举)"); - } else { + if needs_probe { eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)"); + } else { + eprintln!( + "[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)", + options.backend.name() + ); } } diff --git a/crates/app-iced/src/cli.rs b/crates/app-iced/src/cli.rs index 343f1dc..8def288 100644 --- a/crates/app-iced/src/cli.rs +++ b/crates/app-iced/src/cli.rs @@ -45,6 +45,34 @@ pub struct Options { pub scale_factor: Option, /// `--diff `:只做两图逐像素对比,不开窗。 pub diff: Option<(PathBuf, PathBuf)>, + /// `--diff-scan `:对比时额外做 ±n px 的平移扫描(找最佳对齐,默认 0 = 不扫)。 + pub diff_scan: u32, + /// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。 + pub force_state: Option, + /// `--data-dir `:当前**接受并忽略**。 + /// + /// 现役 Slint 版的 CI 冒烟用 `--software-renderer --screenshot --data-dir `, + /// 翻牌后同一套脚本要能直接跑 iced 版;真正的数据目录消费由后续页面 mission 接 `host`/`state` 时做。 + pub data_dir: Option, +} + +/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForcedState { + /// 所有可交互控件按 hover 画。 + Hover, + /// 所有可交互控件按 pressed 画。 + Pressed, +} + +impl ForcedState { + /// 稳定名字(日志用)。 + pub const fn name(self) -> &'static str { + match self { + ForcedState::Hover => "hover", + ForcedState::Pressed => "pressed", + } + } } impl Default for Options { @@ -56,6 +84,9 @@ impl Default for Options { screenshot_theme: None, scale_factor: None, diff: None, + diff_scan: 0, + force_state: None, + data_dir: None, } } } @@ -124,6 +155,28 @@ impl Options { .ok_or_else(|| "--diff 需要两张图的路径".to_owned())?; options.diff = Some((PathBuf::from(left), PathBuf::from(right))); } + "--diff-scan" => { + let raw = take(&mut args, inline, "--diff-scan")?; + options.diff_scan = raw + .parse() + .map_err(|_| format!("--diff-scan 需要非负整数,收到 {raw:?}"))?; + } + "--force-state" => { + let raw = take(&mut args, inline, "--force-state")?; + options.force_state = Some(match raw.as_str() { + "hover" => ForcedState::Hover, + "pressed" => ForcedState::Pressed, + other => { + return Err(format!( + "--force-state 只认 hover|pressed,收到 {other:?}" + )); + } + }); + } + // 翻牌前的兼容参数:接受并忽略(见字段文档) + "--data-dir" => { + options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?)); + } other => return Err(format!("未知参数 {other:?}")), } } @@ -207,6 +260,33 @@ mod tests { assert!(parse(&["--nope"]).is_err()); } + #[test] + fn data_dir_is_accepted_and_ignored_for_the_flip() { + // 现役 CI 冒烟:--software-renderer --screenshot --data-dir + let options = parse(&[ + "--software-renderer", + "--screenshot", + "shot.png", + "--data-dir", + "/d/tmp/x", + ]) + .expect("翻牌前必须兼容 --data-dir"); + assert_eq!(options.backend, Backend::Cpu); + assert_eq!(options.screenshot, Some(PathBuf::from("shot.png"))); + assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x"))); + } + + #[test] + fn diff_scan_and_force_state_parse() { + assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2); + assert_eq!( + parse(&["--force-state", "hover"]).unwrap().force_state, + Some(ForcedState::Hover) + ); + assert!(parse(&["--force-state=focus"]).is_err()); + assert!(parse(&["--diff-scan=-1"]).is_err()); + } + #[test] fn diff_takes_two_paths() { let options = parse(&["--diff", "a.png", "b.png"]).unwrap(); diff --git a/crates/app-iced/src/icons.rs b/crates/app-iced/src/icons.rs index 7085ec4..09ced9a 100644 --- a/crates/app-iced/src/icons.rs +++ b/crates/app-iced/src/icons.rs @@ -55,6 +55,14 @@ fn handle(kind: IconKind) -> svg::Handle { .collect() }); + // 依赖「枚举下标 == IconKind::ALL 下标」(令牌层用 `GLYPHS[self as usize]` 同一约定)。 + // 这条断言把约定钉在测试与 debug 构建里,`IconKind` 一旦乱序插入就会立刻炸。 + debug_assert_eq!( + IconKind::ALL[kind as usize], + kind, + "IconKind::ALL 必须与枚举声明顺序一致" + ); + handles[kind as usize].clone() } diff --git a/crates/app-iced/src/main.rs b/crates/app-iced/src/main.rs index 6410578..b55a913 100644 --- a/crates/app-iced/src/main.rs +++ b/crates/app-iced/src/main.rs @@ -25,7 +25,8 @@ fn main() -> ExitCode { }; if let Some((left, right)) = options.diff.clone() { - return match shot::diff_files(&left, &right) { + let scan = i32::try_from(options.diff_scan).unwrap_or(i32::MAX); + return match shot::diff_files_with_scan(&left, &right, scan) { Ok(diff) => { eprintln!("{}", diff.describe()); if diff.is_identical() { diff --git a/crates/app-iced/src/probe.rs b/crates/app-iced/src/probe.rs index 2fba3f5..ca33f08 100644 --- a/crates/app-iced/src/probe.rs +++ b/crates/app-iced/src/probe.rs @@ -85,8 +85,10 @@ pub fn backend_env(request: Backend, probe: &Probe, gpu_compiled: bool) -> Optio (Backend::Gpu, true) => Some("wgpu,tiny-skia"), // 自动:有硬件就让 iced 走默认顺序(wgpu 优先,自动回退)。 (Backend::Auto, true) if probe.hardware => None, - // 自动 + 无硬件(含只有 WARP 的机器)/ 没编进 wgpu:CPU 起步。 - (Backend::Auto, _) => Some("tiny-skia,wgpu"), + // 自动 + 没编进 wgpu:只有 CPU 候选,别把 wgpu 写进列表(会与「未编入 wgpu」的日志自相矛盾)。 + (Backend::Auto, false) => Some("tiny-skia"), + // 自动 + 有 wgpu 但没硬件(含只有 WARP 的机器):CPU 起步,wgpu 留作兜底。 + (Backend::Auto, true) => Some("tiny-skia,wgpu"), } } @@ -220,12 +222,10 @@ mod tests { Some("wgpu,tiny-skia") ); assert_eq!(backend_env(Backend::Cpu, &gpu, true), Some("tiny-skia")); - // 没编进 wgpu 的构建绝不写 wgpu + // 没编进 wgpu 的构建绝不写 wgpu(连候选列表也不许出现,reviewer P3-4) assert_eq!(backend_env(Backend::Gpu, &gpu, false), Some("tiny-skia")); - assert_eq!( - backend_env(Backend::Auto, &gpu, false), - Some("tiny-skia,wgpu") - ); + assert_eq!(backend_env(Backend::Auto, &gpu, false), Some("tiny-skia")); + assert_eq!(backend_env(Backend::Auto, &none, false), Some("tiny-skia")); } #[test] diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index c44581d..6b85b85 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -20,7 +20,7 @@ mod nav; pub use nav::NavItem; use iced::widget::text::LineHeight; -use iced::widget::{button, column, container, mouse_area, row, space, text}; +use iced::widget::{button, column, container, mouse_area, row, space, stack, text}; use iced::{mouse, Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow}; use iced::{window::Direction, Theme as IcedTheme}; @@ -32,6 +32,7 @@ use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use crate::app::Message; +use crate::cli::ForcedState; use crate::icons; use crate::theme::Ui; @@ -42,39 +43,52 @@ pub fn view<'a>( app_title: &'a str, page_title: &'a str, body: &'a str, + forced: Option, ) -> Element<'a, Message> { let work_area = column![ - titlebar(ui, app_title), - row![nav::rail(ui, selected), content(ui, page_title, body)] - .width(Length::Fill) - .height(Length::Fill), - ] - .width(Length::Fill) - .height(Length::Fill); - - // 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。西/北边见模块文档的已知缺口。 - let thickness = Length::Fixed(ui.space(Space::X1)); - - let shell = column![ + titlebar(ui, app_title, forced), row![ - work_area, - resize_edge( - Direction::East, - (thickness, Length::Fill), - mouse::Interaction::ResizingHorizontally, - ), + nav::rail(ui, selected, forced), + content(ui, page_title, body) ] .width(Length::Fill) .height(Length::Fill), - resize_edge( - Direction::South, - (Length::Fill, thickness), - mouse::Interaction::ResizingVertically, - ), ] .width(Length::Fill) .height(Length::Fill); + // 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。 + // **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px, + // 视觉复审 P3-1)。叠层容器自己铺满,再用 align 把 4px 热区贴到边上,布局宽保持 1180。 + // 东侧热区从标题栏下方开始,避免吃掉三个 caption 按钮最右 4px 的点击区。 + let thickness = ui.space(Space::X1); + + let east = container( + column![ + space::vertical().height(ui.metrics().titlebar_height), + resize_edge( + Direction::East, + (Length::Fixed(thickness), Length::Fill), + mouse::Interaction::ResizingHorizontally, + ), + ] + .height(Length::Fill), + ) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::End); + + let south = container(resize_edge( + Direction::South, + (Length::Fill, Length::Fixed(thickness)), + mouse::Interaction::ResizingVertically, + )) + .width(Length::Fill) + .height(Length::Fill) + .align_y(Alignment::End); + + let shell = stack![work_area, east, south]; + container(shell) .width(Length::Fill) .height(Length::Fill) @@ -101,7 +115,7 @@ fn resize_edge<'a>( // ---------------------------------------------------------------- 标题栏 -fn titlebar<'a>(ui: &'a Ui, title: &'a str) -> Element<'a, Message> { +fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option) -> Element<'a, Message> { let metrics = ui.metrics(); // 拖动热区:从窗口左缘到第一个按钮(按钮自己不能被拖区吞掉,所以只包左侧一段) @@ -122,9 +136,9 @@ fn titlebar<'a>(ui: &'a Ui, title: &'a str) -> Element<'a, Message> { let bar = row![ drag_area, - caption(ui, IconKind::Minimize, Message::Minimize), - caption(ui, IconKind::Maximize, Message::ToggleMaximize), - caption(ui, IconKind::Close, Message::Close), + caption(ui, IconKind::Minimize, Message::Minimize, forced), + caption(ui, IconKind::Maximize, Message::ToggleMaximize, forced), + caption(ui, IconKind::Close, Message::Close, forced), ] .width(Length::Fill) .height(metrics.titlebar_height) @@ -138,41 +152,57 @@ fn titlebar<'a>(ui: &'a Ui, title: &'a str) -> Element<'a, Message> { } /// 标题栏按钮(自绘最小化/最大化/关闭)。 -fn caption<'a>(ui: &'a Ui, kind: IconKind, message: Message) -> Element<'a, Message> { +/// +/// 图标必须**再包一层固定 24×24 的容器**:iced 的 `button` 在 layout 里给内容 Fill 约束, +/// 直接把 `svg(...).width(24)` 塞进 button,usvg 会按 Contain 撑满内区(实测渲染盒 46 → 视觉线宽 +/// 从 1.5 变 2.9,视觉复审 P2-2)。Container 尊重子元素的 Length,所以这层是「锁尺寸」用的。 +fn caption<'a>( + ui: &'a Ui, + kind: IconKind, + message: Message, + forced: Option, +) -> Element<'a, Message> { let metrics = ui.metrics(); - - button(icons::view::( + // 用 `center(Length::Fill)` 而不是只设 `width(24).height(24)`:后者会让容器缩到 24×24 并贴到 + // button 内容的左上角(button 不居中「收缩后」的内容,r1 实测图标跑到 cell 左上 (left+11.5, 11.5)); + // `center(Fill)` = 撑满按钮内容区 + 内容居中,字形仍是 24 盒、命中区仍是 46×48。 + let icon = container(icons::view::( kind, ui.color(Role::OnSurfaceVariant), )) - .width(metrics.titlebar_button_width) - .height(metrics.titlebar_height) - .padding(0) - .style(move |_theme: &IcedTheme, status| { - let background = match status { - button::Status::Hovered => Some(Background::Color(ui.state_over( - StateLayer::Hover, - Role::OnSurface, - Role::Surface, - ))), - button::Status::Pressed => Some(Background::Color(ui.state_over( - StateLayer::Pressed, - Role::OnSurface, - Role::Surface, - ))), - button::Status::Active | button::Status::Disabled => None, - }; + .width(icons::RENDER_PX) + .height(icons::RENDER_PX) + .center(Length::Fill); - button::Style { - background, - text_color: ui.color(Role::OnSurfaceVariant), - border: Border::default(), - shadow: Shadow::default(), - snap: true, - } - }) - .on_press(message) - .into() + button(icon) + .width(metrics.titlebar_button_width) + .height(metrics.titlebar_height) + .padding(0) + .style(move |_theme: &IcedTheme, status| { + let background = match overridden(forced, status) { + button::Status::Hovered => Some(Background::Color(ui.state_over( + StateLayer::Hover, + Role::OnSurface, + Role::Surface, + ))), + button::Status::Pressed => Some(Background::Color(ui.state_over( + StateLayer::Pressed, + Role::OnSurface, + Role::Surface, + ))), + button::Status::Active | button::Status::Disabled => None, + }; + + button::Style { + background, + text_color: ui.color(Role::OnSurfaceVariant), + border: Border::default(), + shadow: Shadow::default(), + snap: true, + } + }) + .on_press(message) + .into() } // ---------------------------------------------------------------- 内容区 @@ -252,6 +282,15 @@ pub(super) fn label<'a>( .into() } +/// 取证用的状态覆盖(`--force-state`):只改画出来的状态,不动任何状态机。 +pub(super) fn overridden(forced: Option, status: button::Status) -> button::Status { + match forced { + Some(ForcedState::Hover) => button::Status::Hovered, + Some(ForcedState::Pressed) => button::Status::Pressed, + None => status, + } +} + pub(super) fn filled(color: Color) -> container::Style { container::Style { background: Some(Background::Color(color)), diff --git a/crates/app-iced/src/shell/nav.rs b/crates/app-iced/src/shell/nav.rs index de96561..bfd3f7d 100644 --- a/crates/app-iced/src/shell/nav.rs +++ b/crates/app-iced/src/shell/nav.rs @@ -12,8 +12,8 @@ //! | 颜色 | 5.6 选中图标 `OnSecondaryContainer` / 选中标签 `Secondary` / 未选中 `OnSurfaceVariant` | `Ui::color` | //! | 项间距 | 5.7 **4** | `LayoutMetrics::nav_item_gap` | -use iced::widget::{button, column, container, space}; -use iced::{Alignment, Background, Border, Element, Length, Shadow, Theme as IcedTheme}; +use iced::widget::{button, column, container}; +use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme}; use unlucky_design::color::{Role, StateLayer}; use unlucky_design::elevation::MaterialLayer; @@ -23,8 +23,9 @@ use unlucky_design::spacing::{LayoutMetrics, Space}; use unlucky_design::typography::TypeRole; use crate::app::Message; +use crate::cli::ForcedState; use crate::icons; -use crate::shell::{filled, label}; +use crate::shell::{filled, label, overridden}; use crate::theme::Ui; /// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。 @@ -76,20 +77,25 @@ impl NavItem { } /// 整条导航轨。 -pub(super) fn rail<'a>(ui: &'a Ui, selected: NavItem) -> Element<'a, Message> { +pub(super) fn rail<'a>( + ui: &'a Ui, + selected: NavItem, + forced: Option, +) -> Element<'a, Message> { let metrics = ui.metrics(); let indicator = indicator_size(metrics); - let mut items: Vec> = - vec![space::vertical().height(ui.space(Space::X6)).into()]; - - for item in NavItem::ALL { - items.push(rail_item(ui, item, item == selected, indicator)); - } + let items = NavItem::ALL + .iter() + .map(|item| rail_item(ui, *item, *item == selected, indicator, forced)) + .collect::>(); + // 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的 + // spacing(4)再加一档,实测变 28(视觉复审 P3-5)。 container(column(items).spacing(metrics.nav_item_gap)) .width(nav_rail_width(metrics)) .height(Length::Fill) + .padding(Padding::from([ui.space(Space::X6), 0.0])) .style(|_theme| filled(ui.layer(MaterialLayer::Canvas))) .into() } @@ -99,6 +105,7 @@ fn rail_item<'a>( item: NavItem, selected: bool, indicator: (f32, f32), + forced: Option, ) -> Element<'a, Message> { let (indicator_width, indicator_height) = indicator; @@ -114,11 +121,14 @@ fn rail_item<'a>( ui.color(Role::OnSurfaceVariant) }; + // **不要用 `center_x/center_y(Fill)`**:那会把容器自己的 width/height 改成 Fill, + // 于是 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) - .center_x(Length::Fill) - .center_y(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) .style(move |_theme| container::Style { background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))), border: Border { @@ -129,11 +139,14 @@ fn rail_item<'a>( }); let item_height = nav_item_height(ui, indicator_height); + // 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作, + // 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。 let stack = column![ pill, label(ui, TypeRole::LabelMedium, false, item.label(), label_color), ] .spacing(ui.space(Space::X1)) + .width(nav_rail_width(ui.metrics())) .align_x(Alignment::Center) .height(item_height); @@ -142,7 +155,7 @@ fn rail_item<'a>( .height(item_height) .padding(0) .style(move |_theme: &IcedTheme, status| { - let background = match status { + let background = match overridden(forced, status) { button::Status::Hovered if !selected => Some(Background::Color(ui.state_over( StateLayer::Hover, Role::OnSurface, @@ -168,21 +181,19 @@ fn rail_item<'a>( .into() } -/// 导航轨宽度(**M69 迁移点**)。 +/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。 /// /// M69(`141e9b1`)把 `LayoutMetrics` 的 nav 字段做了语义纠正: /// /// | 旧 | 新 | /// |---|---| -/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,只是名字从「展开」改成「轨」) | +/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,名字从「展开」改成「轨」) | /// | `nav_collapsed_width = 48` | 删除(48 连 56 宽的选中指示器都放不下) | -/// | —(缺) | 新增 `nav_drawer_width = 240`(D33 的展开态抽屉) | +/// | —(缺) | `nav_drawer_width = 240`(D33 的展开态抽屉,本 crate 未用) | /// -/// 本 crate 的分支基线(`0aac307`)还没有 M69,所以这里只能写旧字段名;**等 M69 合入后 -/// 把函数体那一行改成 `metrics.nav_rail_width` 即可,其余代码与视觉零变化**。 -/// 把三处调用点收敛到这个函数,就是为了让迁移永远只有一行。 +/// 三处调用点都走这里:将来令牌再改名,仍然只改这一行。 fn nav_rail_width(metrics: LayoutMetrics) -> f32 { - metrics.nav_expanded_width + metrics.nav_rail_width } /// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死: diff --git a/crates/app-iced/src/shot.rs b/crates/app-iced/src/shot/diff.rs similarity index 54% rename from crates/app-iced/src/shot.rs rename to crates/app-iced/src/shot/diff.rs index bd4917a..1136891 100644 --- a/crates/app-iced/src/shot.rs +++ b/crates/app-iced/src/shot/diff.rs @@ -1,91 +1,9 @@ -//! 截图通道(D30 验收用):`iced::window::screenshot` 的真窗口出图 → PNG,以及两图逐像素对比。 +//! 两图逐像素对比(GPU/CPU 几何一致性)与 ±N px 平移扫描。 //! -//! ## 为什么用真窗口而不是 `iced_test::Emulator` -//! -//! 任务书首选路径①:真开窗截图覆盖了标题栏/DPI/窗口管理的真实行为,离屏通道不覆盖 -//! (M60 §Q6 的边界说明)。本机与 Windows CI 都有交互会话,所以走路径①。 -//! -//! ## 关键事实(M60 踩坑 Q3) -//! -//! `Screenshot` 的字节字段在 0.14 叫 **`rgba`**(0.13 是 `bytes`),`size` 是**物理像素**, -//! 另带 `scale_factor`(换算逻辑坐标用)。落盘用 `image::save_buffer` + `ExtendedColorType::Rgba8`。 -//! -//! ## 为什么自己带一个 diff -//! -//! D30 的硬项是「GPU 档与 CPU 档**几何逐像素一致**,只有颜色/材质不同」。CI 里不该假设有 -//! Python/PIL/ImageMagick,所以把「读两张 PNG → 数差异像素 → 给最大/平均通道差」做进本程序 -//! (`--diff a.png b.png`),复用同一个二进制。 +//! 拆成子模块只为「src 文件 ≤400 行」的门禁;对外通过 `shot::{compare, diff_files, Diff, Image}` +//! 使用,调用方不用感知这层。 -use std::path::{Path, PathBuf}; - -use iced::window; - -use crate::theme; - -/// 把一帧截图写到 `path`(自动建父目录)。 -/// -/// # Errors -/// 目录建不出来、PNG 编码失败、字节数与尺寸对不上时返回人话(截图通道要能一眼看出哪一步断)。 -pub fn save(screenshot: &window::Screenshot, path: &Path) -> Result { - let width = screenshot.size.width; - let height = screenshot.size.height; - let bytes = screenshot.rgba.as_ref(); - - let expected = width as usize * height as usize * 4; - if bytes.len() != expected { - return Err(format!( - "截图字节数对不上:{width}x{height} 应该 {expected} B,实际 {} B", - bytes.len() - )); - } - - write_rgba(bytes, width, height, path)?; - - Ok(Summary { - width, - height, - scale_factor: screenshot.scale_factor, - path: path.to_path_buf(), - }) -} - -fn write_rgba(bytes: &[u8], width: u32, height: u32, path: &Path) -> Result<(), String> { - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent) - .map_err(|error| format!("建目录 {} 失败:{error}", parent.display()))?; - } - } - - image::save_buffer(path, bytes, width, height, image::ExtendedColorType::Rgba8) - .map_err(|error| format!("写 PNG {} 失败:{error}", path.display())) -} - -/// 一次截图的结果摘要(打日志与报告用)。 -#[derive(Debug, Clone, PartialEq)] -pub struct Summary { - /// 物理宽(px)。 - pub width: u32, - /// 物理高(px)。 - pub height: u32, - /// 窗口缩放因子(100% DPI = 1.0)。 - pub scale_factor: f32, - /// 落盘路径。 - pub path: PathBuf, -} - -impl Summary { - /// 一行日志。 - pub fn describe(&self) -> String { - format!( - "[shot] {}x{} scale={} -> {}", - self.width, - self.height, - self.scale_factor, - self.path.display() - ) - } -} +use std::path::Path; /// 一张已解码的 RGBA 图。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -135,6 +53,10 @@ pub struct Diff { pub max_channel_delta: u8, /// 单通道平均差(0~255)。 pub mean_channel_delta: f64, + /// 平移扫描的最佳对齐(dx, dy);不扫描时为 (0, 0)。 + pub best_shift: (i32, i32), + /// 最佳对齐下的差异像素数(不扫描时等于 `differing_pixels`)。 + pub best_differing_pixels: usize, } /// 逐像素对比两张尺寸相同的图。 @@ -182,9 +104,73 @@ pub fn compare(left: &Image, right: &Image) -> Result { } else { sum_delta as f64 / (pixel_count * 4) as f64 }, + best_shift: (0, 0), + best_differing_pixels: differing, }) } +/// ±`max_shift` px 的平移扫描:把 `right` 平移后再比,返回差异像素数最小的那一档。 +/// +/// 用途:D30 的「几何逐像素一致」除了看差异占比,还要证明**没有整体错位**—— +/// 如果 (0,0) 就是最优,说明差异只来自抗锯齿而不是位移/缩放。CI 不必再依赖 numpy。 +/// +/// 平移后越界的像素按「无法比较」处理,不计入差异(否则边缘会造成假阳性)。 +pub fn shift_scan(left: &Image, right: &Image, max_shift: i32) -> Result { + let mut best = compare(left, right)?; + let max_shift = max_shift.unsigned_abs(); + + if (left.width, left.height) != (right.width, right.height) { + return Err("尺寸不同,无法做平移扫描".to_owned()); + } + + for dy in -(max_shift as i32)..=(max_shift as i32) { + for dx in -(max_shift as i32)..=(max_shift as i32) { + if (dx, dy) == (0, 0) { + continue; + } + + let count = shifted_differing_pixels(left, right, dx, dy); + if count < best.best_differing_pixels { + best.best_differing_pixels = count; + best.best_shift = (dx, dy); + } + } + } + + Ok(best) +} + +/// 把 `right` 平移 `(dx, dy)` 后与 `left` 比较,返回差异像素数(越界不算)。 +fn shifted_differing_pixels(left: &Image, right: &Image, dx: i32, dy: i32) -> usize { + let width = left.width as i32; + let height = left.height as i32; + let mut differing = 0; + + for y in 0..height { + let source_y = y - dy; + if !(0..height).contains(&source_y) { + continue; + } + + for x in 0..width { + let source_x = x - dx; + if !(0..width).contains(&source_x) { + continue; + } + + let left_offset = ((y * width + x) * 4) as usize; + let right_offset = ((source_y * width + source_x) * 4) as usize; + + if left.rgba[left_offset..left_offset + 4] != right.rgba[right_offset..right_offset + 4] + { + differing += 1; + } + } + } + + differing +} + impl Diff { /// 完全一致(几何 + 颜色都一致)。 pub fn is_identical(&self) -> bool { @@ -201,13 +187,16 @@ impl Diff { }; format!( - "[diff] {}x{} differing={}/{} ({percent:.2}%) max_delta={} mean_delta={:.4}", + "[diff] {}x{} differing={}/{} ({percent:.2}%) max_delta={} mean_delta={:.4} best_shift=({},{}) best_differing={}", self.width, self.height, self.differing_pixels, total, self.max_channel_delta, - self.mean_channel_delta + self.mean_channel_delta, + self.best_shift.0, + self.best_shift.1, + self.best_differing_pixels, ) } } @@ -217,22 +206,32 @@ impl Diff { /// # Errors /// 任一图读不出来,或尺寸不同。 pub fn diff_files(left: &Path, right: &Path) -> Result { - let left = Image::load(left)?; - let right = Image::load(right)?; - compare(&left, &right) + diff_files_with_scan(left, right, 0) } -/// 主题名 → 归档文件名片段(`visual-acceptance-protocol.md` §7 的命名)。 -pub fn flavor_slug(ui: &theme::Ui) -> &'static str { - match ui.flavor() { - unlucky_design::color::SchemeFlavor::Light => "light", - unlucky_design::color::SchemeFlavor::Dark => "dark", +/// 同 [`diff_files`],但额外做 ±`max_shift` px 的平移扫描(`--diff-scan`)。 +/// +/// # Errors +/// 任一图读不出来,或尺寸不同。 +pub fn diff_files_with_scan(left: &Path, right: &Path, max_shift: i32) -> Result { + let left = Image::load(left)?; + let right = Image::load(right)?; + + if max_shift == 0 { + compare(&left, &right) + } else { + shift_scan(&left, &right, max_shift) } } #[cfg(test)] mod tests { + use std::path::PathBuf; + + use iced::window; + use super::*; + use crate::shot::{save, write_rgba}; fn temp_dir() -> PathBuf { let dir = std::env::temp_dir().join("ull-app-iced-shot-tests"); @@ -299,6 +298,51 @@ mod tests { assert!(diff.is_identical()); } + #[test] + fn shift_scan_finds_the_best_alignment() { + // 5x5 图,R 通道存唯一值;构造 right[y][x] = left[y+1][x+1](即把左图向左上挪 1px), + // 于是「left 与 right 在 dy=1,dx=1 时逐像素相等」——扫描应该把 (1,1) 找出来。 + let side = 5_i32; + let mut rgba = Vec::new(); + for y in 0..side { + for x in 0..side { + rgba.extend([(y * side + x) as u8, 0, 0, 255]); + } + } + let left = Image { + rgba, + width: side as u32, + height: side as u32, + }; + + let mut moved = vec![255_u8; (side * side * 4) as usize]; + for y in 0..side - 1 { + for x in 0..side - 1 { + let source = ((y + 1) * side + (x + 1)) as usize * 4; + let target = (y * side + x) as usize * 4; + moved[target..target + 4].copy_from_slice(&left.rgba[source..source + 4]); + } + } + let right = Image { + rgba: moved, + width: side as u32, + height: side as u32, + }; + + let diff = shift_scan(&left, &right, 2).unwrap(); + assert_eq!(diff.best_shift, (1, 1), "应扫出 1px 的位移"); + assert_eq!(diff.best_differing_pixels, 0, "对齐后应逐像素相等"); + assert!( + diff.differing_pixels > 0, + "不对齐时应该有差异(否则这条测试没有信息量)" + ); + + // 同一张图:最佳对齐就是 (0,0) + let same = shift_scan(&left, &left, 2).unwrap(); + assert_eq!(same.best_shift, (0, 0)); + assert_eq!(same.best_differing_pixels, 0); + } + #[test] fn wrong_byte_count_is_reported() { let screenshot = window::Screenshot::new(vec![0_u8; 3], iced::Size::new(1, 1), 1.0); diff --git a/crates/app-iced/src/shot/mod.rs b/crates/app-iced/src/shot/mod.rs new file mode 100644 index 0000000..1e2bb32 --- /dev/null +++ b/crates/app-iced/src/shot/mod.rs @@ -0,0 +1,100 @@ +//! 截图通道(D30 验收用):`iced::window::screenshot` 的真窗口出图 → PNG,以及两图逐像素对比。 +//! +//! ## 为什么用真窗口而不是 `iced_test::Emulator` +//! +//! 任务书首选路径①:真开窗截图覆盖了标题栏/DPI/窗口管理的真实行为,离屏通道不覆盖 +//! (M60 §Q6 的边界说明)。本机与 Windows CI 都有交互会话,所以走路径①。 +//! +//! ## 关键事实(M60 踩坑 Q3) +//! +//! `Screenshot` 的字节字段在 0.14 叫 **`rgba`**(0.13 是 `bytes`),`size` 是**物理像素**, +//! 另带 `scale_factor`(换算逻辑坐标用)。落盘用 `image::save_buffer` + `ExtendedColorType::Rgba8`。 +//! +//! ## 为什么自己带一个 diff +//! +//! D30 的硬项是「GPU 档与 CPU 档**几何逐像素一致**,只有颜色/材质不同」。CI 里不该假设有 +//! Python/PIL/ImageMagick,所以把「读两张 PNG → 数差异像素 → 给最大/平均通道差」做进本程序 +//! (`--diff a.png b.png`),复用同一个二进制。 + +use std::path::{Path, PathBuf}; + +use iced::window; + +use crate::theme; + +/// 把一帧截图写到 `path`(自动建父目录)。 +/// +/// # Errors +/// 目录建不出来、PNG 编码失败、字节数与尺寸对不上时返回人话(截图通道要能一眼看出哪一步断)。 +pub fn save(screenshot: &window::Screenshot, path: &Path) -> Result { + let width = screenshot.size.width; + let height = screenshot.size.height; + let bytes = screenshot.rgba.as_ref(); + + let expected = width as usize * height as usize * 4; + if bytes.len() != expected { + return Err(format!( + "截图字节数对不上:{width}x{height} 应该 {expected} B,实际 {} B", + bytes.len() + )); + } + + write_rgba(bytes, width, height, path)?; + + Ok(Summary { + width, + height, + scale_factor: screenshot.scale_factor, + path: path.to_path_buf(), + }) +} + +fn write_rgba(bytes: &[u8], width: u32, height: u32, path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("建目录 {} 失败:{error}", parent.display()))?; + } + } + + image::save_buffer(path, bytes, width, height, image::ExtendedColorType::Rgba8) + .map_err(|error| format!("写 PNG {} 失败:{error}", path.display())) +} + +/// 一次截图的结果摘要(打日志与报告用)。 +#[derive(Debug, Clone, PartialEq)] +pub struct Summary { + /// 物理宽(px)。 + pub width: u32, + /// 物理高(px)。 + pub height: u32, + /// 窗口缩放因子(100% DPI = 1.0)。 + pub scale_factor: f32, + /// 落盘路径。 + pub path: PathBuf, +} + +impl Summary { + /// 一行日志。 + pub fn describe(&self) -> String { + format!( + "[shot] {}x{} scale={} -> {}", + self.width, + self.height, + self.scale_factor, + self.path.display() + ) + } +} + +mod diff; + +pub use diff::{compare, diff_files, diff_files_with_scan, shift_scan, Diff, Image}; + +/// 主题名 → 归档文件名片段(`visual-acceptance-protocol.md` §7 的命名)。 +pub fn flavor_slug(ui: &theme::Ui) -> &'static str { + match ui.flavor() { + unlucky_design::color::SchemeFlavor::Light => "light", + unlucky_design::color::SchemeFlavor::Dark => "dark", + } +} diff --git a/crates/app-iced/src/theme.rs b/crates/app-iced/src/theme.rs index fe3655a..feaa65d 100644 --- a/crates/app-iced/src/theme.rs +++ b/crates/app-iced/src/theme.rs @@ -29,13 +29,11 @@ use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::{LayoutMetrics, Space}; use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight}; -/// 全站描边宽度(逻辑 px @100% DPI)。rubric G7 要求全站 1px —— 只许这一处定义。 +/// 全站描边宽度(逻辑 px @100% DPI)。rubric G7 要求全站 1px —— 令牌层的「一像素线单一真源」。 /// -/// **M69 迁移点**:M69(`141e9b1`)起令牌层有「一像素线单一真源」 -/// `unlucky_design::stroke::HAIRLINE = 1.0`(容器描边/分隔线/聚焦环都用它)。 -/// 本 crate 的分支基线(`0aac307`)还没有这个模块;M69 合入后把这个常量换成 -/// `pub use unlucky_design::stroke::HAIRLINE as STROKE_PX;` 即可(数值与被引用的地方都不动)。 -pub const STROKE_PX: f32 = 1.0; +/// M69 起令牌层提供 `unlucky_design::stroke::HAIRLINE`(容器描边/分隔线/聚焦环共用), +/// 这里**再导出**而不是自己写 `1.0`:全仓只有令牌层一个定义点。 +pub use unlucky_design::stroke::HAIRLINE as STROKE_PX; /// 已经解析到系统的字体族名(`&'static str` 才能塞进 `iced::Font`)。 /// diff --git a/crates/app-iced/tests/no_hardcoded_tokens.rs b/crates/app-iced/tests/no_hardcoded_tokens.rs index 649fb66..5c1edff 100644 --- a/crates/app-iced/tests/no_hardcoded_tokens.rs +++ b/crates/app-iced/tests/no_hardcoded_tokens.rs @@ -72,15 +72,22 @@ fn hex_color_at(bytes: &[u8], start: usize) -> Option<&str> { None } -/// 带数值参数的取色构造(`Color::from_rgb(0.1, …)`、`color!(0x12, …)`)。 -const NUMERIC_COLOR_CALLS: [&str; 5] = [ +/// 带数值参数的取色构造(`Color::from_rgb(0.1, …)`、`Color::from_rgb8(103, 80, 164)`、`color!(0x12, …)`)。 +/// +/// **子串匹配要注意前后缀**:`from_rgb(` 匹配不到 `from_rgb8(`,所以 8 bit 那个必须单独列出来 +/// (reviewer 造样就是这么绕过去的)。 +const NUMERIC_COLOR_CALLS: [&str; 8] = [ "Color::from_rgb(", + "Color::from_rgb8(", "Color::from_rgba(", "Color::from_rgba8(", "Color::from_hex(", + "Color::rgba(", + "Color::rgb8(", "color!(", ]; +/// 取色构造后面是不是跟着数值字面量(允许 `Color::from_rgba8(color.r, …)` 这类搬运)。 fn numeric_color_call(text: &str) -> Option<&'static str> { for pattern in NUMERIC_COLOR_CALLS { let mut from = 0; @@ -100,6 +107,28 @@ fn numeric_color_call(text: &str) -> Option<&'static str> { None } +/// 结构体字面量取色:`Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }`。 +/// +/// 判据:`Color {` 之后 64 个字符内同时出现 `r:` 与 `g:` 且有数字(`Color { a: 0.0, .. }` 这种 +/// 只改 alpha 的写法不会命中,因为它没有 `r:`)。 +fn color_struct_literal(text: &str) -> bool { + let mut from = 0; + while let Some(found) = text[from..].find("Color {") { + let after = from + found + "Color {".len(); + let window: String = text[after..].chars().take(64).collect(); + let has_channels = window.contains("r:") && window.contains("g:"); + let has_number = window.chars().any(|ch| ch.is_ascii_digit()); + + if has_channels && has_number { + return true; + } + + from = after; + } + + false +} + #[test] fn no_hardcoded_colors_in_sources() { let mut offenders = Vec::new(); @@ -128,6 +157,14 @@ fn no_hardcoded_colors_in_sources() { index + 1 )); } + + if color_struct_literal(line) { + offenders.push(format!( + "{}:{} {line:?} -> Color {{ r: … }} 结构体字面量", + path.display(), + index + 1 + )); + } } } @@ -139,8 +176,20 @@ fn no_hardcoded_colors_in_sources() { } /// 判断某个布局调用后面是不是直接跟了尺寸字面量;`0`(= 无内边距)放行。 +/// +/// iced 里尺寸经常被包一层:`.width(Length::Fixed(123.0))`、`.padding(Padding::from([12.0, 8.0]))`、 +/// `.size(Size::new(10.0, 10.0))` —— 所以遇到这些包装要**往里剥一层**再判。 fn size_literal(argument: &str) -> Option { - let number: String = argument + let mut rest = argument.trim_start(); + + for wrapper in ["Length::Fixed(", "Padding::from(", "Size::new("] { + if let Some(after) = rest.strip_prefix(wrapper) { + rest = after.trim_start().trim_start_matches(['[', '(']); + break; + } + } + + let number: String = rest .trim_start() .chars() .take_while(|ch| ch.is_ascii_digit() || *ch == '.') @@ -169,6 +218,11 @@ fn shell_takes_every_size_from_tokens() { let text = std::fs::read_to_string(&path).expect("读 shell 源文件"); for (index, line) in text.lines().enumerate() { + // 文档注释是散文不是代码:里面写 `.width(24)` 是在解释坑,不该判违规 + if line.trim_start().starts_with("//") { + continue; + } + for pattern in [".width(", ".height(", ".size(", ".spacing(", ".padding("] { let mut from = 0; while let Some(found) = line[from..].find(pattern) { @@ -232,7 +286,29 @@ fn this_crate_never_depends_on_slint() { #[test] fn the_gate_itself_catches_a_planted_violation() { - // 自检:把违规样本喂给同一套判定,必须被抓住(否则门禁是空转) + // 自检:把违规样本喂给同一套判定,必须被抓住(否则门禁是空转)。 + // **reviewer r1 造样验证过的 5 个漏网样本逐条钉在这里**,退化成「放行」立刻红。 + + // 1. `Color::from_rgb8(103, 80, 164)` —— 8bit 版曾因 `from_rgb(` 匹配不到而漏 + assert!(numeric_color_call("Color::from_rgb8(103, 80, 164)").is_some()); + // 2. `iced::Color::from_rgb8(...)` —— 带路径前缀的写法同样要拦 + assert!(numeric_color_call("iced::Color::from_rgb8(103, 80, 164)").is_some()); + // 3. `Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }` —— 结构体字面量 + assert!(color_struct_literal( + "Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }" + )); + // 4. `.width(Length::Fixed(123.0))` —— 尺寸被 iced 包装一层 + assert_eq!( + size_literal("Length::Fixed(123.0))").as_deref(), + Some("123.0") + ); + // 5. `.padding(Padding::from([12.0, 8.0]))` —— 数组字面量 + assert_eq!( + size_literal("Padding::from([12.0, 8.0]))").as_deref(), + Some("12.0") + ); + + // 原有规则不能退化 assert!(hex_color_at(b" color: #6750A4,", 11).is_some()); assert!(hex_color_at(b"// placeholder #RRGGBB here", 15).is_none()); assert!(numeric_color_call("Color::from_rgb(0.1, 0.2, 0.3)").is_some()); @@ -241,6 +317,35 @@ fn the_gate_itself_catches_a_planted_violation() { assert!(size_literal("40.0)").is_some()); assert!(size_literal("0)").is_none()); assert!(size_literal("metrics.titlebar_height)").is_none()); + assert!(size_literal("Length::Fixed(thickness))").is_none()); + assert!(size_literal("Padding::from([0.0, ui.space(Space::X4)]))").is_none()); + assert!(!color_struct_literal( + "Color::from_rgba8(color.r, color.g, color.b, 1.0)" + )); + assert!(!color_struct_literal("let color = Color { a: 0.5 };")); assert!(!manifest_has_slint_dependency("slint = \"1.18\"").is_empty()); assert!(manifest_has_slint_dependency("# 不得依赖 slint").is_empty()); } + +/// 渲染层回归守门(视觉复审 P2-1 的教训):`indicator_matches_the_reference_56x32` 只测纯函数, +/// 门禁全绿但图上的药丸实际是 88×32 —— 因为 `.center_x(Length::Fill)` 会把容器 width 覆盖成 Fill。 +/// 这里直接扫 `nav.rs`:药丸容器**不许**出现 `center_x(` / `center_y(`。 +#[test] +fn pill_never_overrides_its_size_with_fill() { + let nav = manifest_dir().join("src").join("shell").join("nav.rs"); + let text = std::fs::read_to_string(&nav).expect("读 nav.rs"); + + for (index, line) in text.lines().enumerate() { + let code = line.trim_start(); + if code.starts_with("//") { + continue; + } + for pattern in ["center_x(", "center_y("] { + assert!( + !code.contains(pattern), + "nav.rs:{} 用了 {pattern}——`center_*(Length::Fill)` 会把容器尺寸改成 Fill, 药丸会被父级宽度覆盖(视觉复审 P2-1)。居中请用父级 `align_x`:{line}", + index + 1 + ); + } + } +}