fix(app-iced): M79 r2 —— 分隔线守卫换成「锚定细线行 + 整段跨度数段」

代码复审 r2 P1-1:上一条守卫只认 r1 那种「半透明 AA 缝」,四处假通过。
两个结构性原因与对应修法:

1. **窗口被截成 150px**:`longest_h_line` 凑满 150px 就 return,`line_segments` 只在那
   150px 里数段 → 分隔线实长 242px,只看前 62%;缺口在窗口外看不见、在窗口内则被
   「跳到缺口之后重新起跑」滑过。现在 `find_thin_line` 返回的是**该行整段墨迹跨度**,
   `line_segments` 在这个跨度上数段。
2. **行的锚定不稳**:原来「第一条 ≥150px 的连续带」能被五态行的 40px 高底色带、
   `Primary` 指示条顶替(mutE/mutF 会量到完全不同的元素)。现在四条判据一起锚定:
   跨度 ≥200 · 近乎实心(墨迹 ≥ 跨度×4/5)· 薄(跨中点纵向墨迹 ≤3 行)· 低对比
   (线色与底色单通道差 ≤120)。

`TOL=6` 的口径写进注释:**单通道**最大可辨差;40% 覆盖的抗锯齿行实测差 `(9,6,4)`,
6 能看见它又挡住 ±3 渲染噪声;相邻那行是满覆盖(线色差 25/17/10),所以行锚定不会丢。

五次验收(真跑 probe,退出码即判据;变异图在 `target/m79/{mutB,mutC,mutE,mutF}/`
由 r2/r1 图改像素生成,不入库):

| 图 | 结果 | 实测 |
|---|---|---|
| r2 原图 | **rc=0 绿** | 1 段 |
| `before/`(r1 逐 tab + AA 缝) | **rc=101 红** | 3 段,y=471 x24..265 |
| mutB(缺口 x=200..201,旧窗口外) | **rc=101 红** | 2 段,y=471 x24..266 |
| mutC(缺口 x=100..101,旧窗口内) | **rc=101 红** | 2 段,y=471 x24..266 |
| mutE(r1 两个缝位 102/192 挖真缝) | **rc=101 红** | 3 段,**y=471 x24..266**(仍是分隔线那行,不是 y=503 的五态带) |
| mutF(额外:r1 分段线 + 上方 200px Primary 带) | **rc=101 红** | 3 段,y=471(锚定没被 Primary 带顶替) |

门禁:fmt ✓ · `cargo test -p unlucky-app-iced` 197 passed · 全套 probe 对 r2 图 5 passed ·
`clippy --all-targets -D warnings` 干净。未改任何组件实现。
This commit is contained in:
AskaEth committed 2026-11-02 11:56:06 +08:00
1 parent 55a775fca7
commit dffae2c429
2 files changed
+77 -26

No files matched your search

@@ -198,20 +198,21 @@ fn tabs_indicator_tracks_the_selected_tab() {
);
measured.push((bar.width(), ink));
}
// 6.6:Secondary 的分隔线必须是**一条连续直线**(逐 tab 分段画会在相邻段之间留 1px 亮缝,
// 6.6:Secondary 的分隔线必须是**一条连续直线**(逐 tab 分段画会在相邻段之间留缝,
// 视觉 r1 P2-2 实测 `25..101 | 103..191 | 193..265`)。
//
// 判法:1px 的线画在分数像素上 → 真实像素是「线色 ⊗ 页面底色」的**混合值**(实测
// `(221,233,245)`,不是 `SurfaceVariant` 的原值),所以不能按精确色找块。改成:
// 先找「与页面底色不同、横跨 >150px 的那一行」,再用**该行自己的颜色**数连通段。
// 判法(三处都是被真实假通过逼出来的,见 `scan::Shot::find_thin_line` 文档):
// ① 按「细 + 近乎实心 + 低对比 + 跨度 ≥200」锚定**分隔线那一行**(跳过标题行、
// 五态行的 40px 底色带、Primary 指示条);
// ② 在该行的**整段墨迹跨度**上数段——不是在某次 run 的 150px 窗口里数,否则缺口会被滑过。
let surface = rgb(ui.color(Role::Surface));
let divider = shot
.longest_h_line(surface, 150)
.expect("Secondary 那一行应有一条长横线");
let segments = shot.line_segments(divider.0, divider.1, divider.2);
let (y, x0, x1) = shot
.find_thin_line(surface, 200, 120)
.expect("Secondary 那一行应有一条细长横线");
let segments = shot.line_segments(y, x0, x1);
assert_eq!(
segments, 1,
"Secondary 分隔线应是一条直线,实测 {segments} 段(分段画会留缝)"
"Secondary 分隔线应是一条直线,实测 {segments} 段(量到 y={y}, x{x0}..{x1})—— 分段画会在交界处留缝"
);
let (short_bar, long_bar) = (measured[0].0, measured[1].0);
@@ -181,30 +181,80 @@ impl Shot {
span
}
/// 全图里**第一行**横跨 ≥ `min_len` 的「与底色不同」的连续像素:返回 `(y, x0, x1)`。
/// 找「一条**细、低对比、近乎实心**的长横线」,返回 `(y, x0, x1)`:
/// `y` 是线最实的那一行,`x0..x1` 是**该行整段墨迹的跨度**(不是某一次连续 run 的窗口)。
///
/// 用于找 1px 的分隔线:它的颜色是混合值,不能按精确色找。
pub(super) fn longest_h_line(
/// 四条判据,每条都对应一次真实的假通过(视觉 r1 P2-2 复核时被点出来的):
///
/// 1. **跨度 ≥ `min_len`**(200):`Secondary` 分隔线整行 242px;标题/标签行凑不满,能跳过它们。
/// 2. **近乎实心**(墨迹数 ≥ 跨度 × 4/5):逐 tab 分段时中间是真缺口,但只有 1~2px,仍算实心
/// ——**必须先能找到这一行**,再由 [`Shot::line_segments`] 把缺口数成多段。
/// (早先「凑满 150px 就 return,然后在窗口里数段」的写法会滑过缺口,永远数到 1。)
/// 3. **薄**(跨中点纵向墨迹 ≤ 3 行):五态行里的 hover 底色是 40px 高的带,被这条排除
/// (早先的写法会把那条 224px 的带选中,量到完全不同的元素)。
/// 4. **低对比**(线色与底色的单通道最大差 ≤ `max_contrast`):`Primary` 指示条是高饱和色,被排除。
///
/// `TOL` 的口径:**单通道**最大可辨差,取 6。40% 覆盖的抗锯齿行实测单通道差 `(9,6,4)`,
/// 6 能看见它、又挡住 ±3 的渲染噪声;覆盖再低的行会落到判据外,但**相邻那行是满覆盖**
/// (实测线色差 25/17/10),所以行的锚定不会丢。
pub(super) fn find_thin_line(
&self,
background: Rgb,
min_len: usize,
max_contrast: u8,
) -> Option<(usize, usize, usize)> {
const TOL: u8 = 6;
let is_ink = |pixel: Rgb| differs(pixel, background, TOL);
let contrast = |pixel: Rgb| {
pixel
.iter()
.zip(background)
.map(|(p, q)| p.abs_diff(q))
.max()
.unwrap_or(0)
};
for y in 0..self.height {
let mut run = 0;
let mut start = 0;
for x in 0..self.width {
if differs(self.at(x, y), background, 6) {
if run == 0 {
start = x;
}
run += 1;
if run >= min_len {
return Some((y, start, x));
}
} else {
run = 0;
}
let ink: Vec<usize> = (0..self.width).filter(|x| is_ink(self.at(*x, y))).collect();
let (Some(&first), Some(&last)) = (ink.first(), ink.last()) else {
continue;
};
let len = last - first + 1;
if len < min_len || ink.len() * 5 < len * 4 {
continue;
}
// 薄:跨中点纵向墨迹 ≤ 3 行
let mid = (first + last) / 2;
let mut top = y;
while top > 0 && is_ink(self.at(mid, top - 1)) {
top -= 1;
}
let mut bottom = y;
while bottom + 1 < self.height && is_ink(self.at(mid, bottom + 1)) {
bottom += 1;
}
if bottom - top + 1 > 3 {
continue;
}
// 低对比:取这条细带里最实的一行(满覆盖),跨段与线色都按那一行算
let best = (top..=bottom)
.max_by_key(|candidate| {
(first..=last)
.filter(|x| is_ink(self.at(*x, *candidate)))
.count()
})
.unwrap_or(y);
let line = self.at(
(first..=last)
.find(|x| is_ink(self.at(*x, best)))
.unwrap_or(mid),
best,
);
let c = contrast(line);
if c > max_contrast || c <= TOL {
continue;
}
return Some((best, first, last));
}
None
}