fix(app-iced): M68 r1 —— M69 迁移 + 守门补洞 + 药丸 56×32 + caption 图标 24 + 东侧热区回 1180
代码复审 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 定案后重拍
This commit is contained in:
1 parent
14da7b3dec
commit
a1adf024ba
11 files changed
+599
-200
No files matched your search
@@ -58,6 +58,8 @@ pub struct App {
|
||||
nav: NavItem,
|
||||
window: Option<window::Id>,
|
||||
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<Message> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,34 @@ pub struct Options {
|
||||
pub scale_factor: Option<f32>,
|
||||
/// `--diff <left> <right>`:只做两图逐像素对比,不开窗。
|
||||
pub diff: Option<(PathBuf, PathBuf)>,
|
||||
/// `--diff-scan <n>`:对比时额外做 ±n px 的平移扫描(找最佳对齐,默认 0 = 不扫)。
|
||||
pub diff_scan: u32,
|
||||
/// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。
|
||||
pub force_state: Option<ForcedState>,
|
||||
/// `--data-dir <dir>`:当前**接受并忽略**。
|
||||
///
|
||||
/// 现役 Slint 版的 CI 冒烟用 `--software-renderer --screenshot <png> --data-dir <dir>`,
|
||||
/// 翻牌后同一套脚本要能直接跑 iced 版;真正的数据目录消费由后续页面 mission 接 `host`/`state` 时做。
|
||||
pub data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。
|
||||
#[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 <png> --data-dir <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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<ForcedState>,
|
||||
) -> 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<ForcedState>) -> 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<ForcedState>,
|
||||
) -> Element<'a, Message> {
|
||||
let metrics = ui.metrics();
|
||||
|
||||
button(icons::view::<Message>(
|
||||
// 用 `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::<Message>(
|
||||
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<ForcedState>, 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)),
|
||||
|
||||
@@ -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<ForcedState>,
|
||||
) -> Element<'a, Message> {
|
||||
let metrics = ui.metrics();
|
||||
let indicator = indicator_size(metrics);
|
||||
|
||||
let mut items: Vec<Element<'a, Message>> =
|
||||
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::<Vec<_>>();
|
||||
|
||||
// 轨顶留白走 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<ForcedState>,
|
||||
) -> 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::<Message>(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),全部由令牌推导、不写死:
|
||||
|
||||
@@ -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<Summary, String> {
|
||||
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<Diff, String> {
|
||||
} 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<Diff, String> {
|
||||
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<Diff, String> {
|
||||
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<Diff, String> {
|
||||
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);
|
||||
@@ -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<Summary, String> {
|
||||
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",
|
||||
}
|
||||
}
|
||||
@@ -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`)。
|
||||
///
|
||||
|
||||
@@ -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<String> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user