From d4e73aacdd1fef22b3721f2093e62459b4cd5576 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 15:40:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(iced/feedback):=20=E9=80=90=E7=8E=AF?= =?UTF-8?q?=E6=94=B9=E5=9B=BA=E5=AE=9A=E7=AA=97=E5=88=A4=E6=8D=AE=20+=20?= =?UTF-8?q?=E8=B4=B4=E9=A1=B6=E8=BE=B9=E8=B7=9D=20+=20Dialog=20=E4=B8=8B?= =?UTF-8?q?=E9=99=90=E5=8D=95=E6=B5=8B=EF=BC=88=E5=A4=8D=E5=AE=A1=20r3=20P?= =?UTF-8?q?2-3/P2-1/P2-2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D45 封顶轮的最后一改,只动这三条: - **P2-3 逐环断言(假绿)**:环本来就在固定 x 栅格(24 + 56·i、宽 40),判据从「主色连通簇数 非空」改成**六个窗逐个测**「窗内墨迹(轨道 ∪ 激活并集)外接成 40(±2)×40(±2)、且不越窗」, 外加「1..5 号窗必须有激活色弧」。簇数只打印不再当判据。G18-i 从「欠账」改成**已收口**, 并删掉模块文档里「要么把 6 个环摆成固定 x 栅格」那句错话(它们本来就在栅格上)。 环带定位也换过两版(「每行墨点 4..20」会把 40 切成 32;「首行..末行」在深色档扩到 274 行), 最终用「第一个窗里连续有墨、高度≈40 的行组」。 变异:涂掉 4 个环 → `第 1 个窗里没有任何环墨迹:环丢了` 红;25% 环缩成 20×20 → `第 1 个窗里的环应 40×40(±2),实测 20×20` 红。 - **P2-1 贴顶边距**:新增「框顶行 → 条顶行 = 36±1」与「取景框高 = 96±1」两条。 变异(`HOST_SHORT` 96 → 72,重编重拍):`D:贴底 ⇒ 框顶到条顶应为 36(96 − 48 − 12),实测 12` 红; 还原后绿。—— 之前只量「条底距框底」,宿高回退成 72 时确实抓不住。 顺带修了取景框判据本身:条自身(深色)比画布更接近框色,会把条自己认成框(实测差值 1), 现在要求「框色连续段比条本身更宽」。 - **P2-2 Dialog 下限**:补单测 `effective_width(200.0) == 280.0`(360/800 两点一并钉住), 并把「不低于官方 280」写进 `Dialog::width` 的文档。 - 欠账登记(只写表、不改代码):G18-j(动作色用全图合计,单卡漂移可能被别卡盖住)、 G18-k(再 4 处文档残留,不为凑数扩大 diff)、G18-l(取景框判据会把条的抗锯齿底行算成框, 余量 0.0008,方向是误报;加了「框高 96」后触发概率下降)。 --- .../src/components/feedback/dialog.rs | 3 +- .../app-iced/src/components/feedback/mod.rs | 4 + .../app-iced/src/components/feedback/probe.rs | 48 +++-- .../src/components/feedback/probe/circular.rs | 171 ++++++++++++++---- .../app-iced/src/components/feedback/tests.rs | 20 ++ 5 files changed, 198 insertions(+), 48 deletions(-) diff --git a/crates/app-iced/src/components/feedback/dialog.rs b/crates/app-iced/src/components/feedback/dialog.rs index 09eed0d..78cae57 100644 --- a/crates/app-iced/src/components/feedback/dialog.rs +++ b/crates/app-iced/src/components/feedback/dialog.rs @@ -99,7 +99,8 @@ impl<'a, M> Dialog<'a, M> { } } - /// 可用宽(默认 480,组件取 `min(available, 480)`)。 + /// 可用宽(默认 480)。实际宽 = `min(available, 480)`,且**不低于官方下限 280** + /// (`DialogMinWidth`;可用宽传 200 时仍画 280 并溢出宿主 —— iced 无 min-width,见 G17-d)。 /// /// 为什么不让组件自己从宿主推:iced 的 `Length` 没有 min-width,而 `Fill + max_width` /// 会被父级的精确分配顶掉(实测渲染成 548 而不是 480,见 `dialog()` 里的说明)。 diff --git a/crates/app-iced/src/components/feedback/mod.rs b/crates/app-iced/src/components/feedback/mod.rs index 2e8ef13..c3860b8 100644 --- a/crates/app-iced/src/components/feedback/mod.rs +++ b/crates/app-iced/src/components/feedback/mod.rs @@ -42,6 +42,10 @@ //! | D-4 | Snackbar L3 阴影 | 无阴影 | 同 D-1 | 是 | //! | G17-d | Snackbar 最小宽 344 | 组件不强制(宿主保证) | iced `Length` 无 min-width(同按钮 G15) | 是(自定义 Widget) | //! | D-5 | 深色档 scrim 压暗 32% | 保持 0.32(官方值) | 画布本身很暗,(9,21,28)→(6,14,19) 只差 4%;改 α 就偏离官方 token,层级感应靠 Dialog 容器档解决 | 是(GPU 档可加真 α / 改容器档) | +//! | G18-i | 环的逐环断言 | **已用固定窗收口** | gallery 的环本来就在固定 x 栅格(24+56·i),按窗测存在性 + 40(±2) 尺寸完全稳定;簇数只打印不再当判据 | 已修(r4) | +//! | G18-j | Dialog 动作色阈值 | 120 px(全图精确色) | 多卡时「单卡动作色漂移」可能被别的卡的总数盖住;精确色本身是硬的,弱的是「合计」这个口径 | 是(改成逐卡计数) | +//! | G18-k | 文档残留 | 本轮只改了能确证的行 | 复审又找出 4 处同类残留;不为凑数扩大 diff,留待下次触碰该文件时顺手改 | 是 | +//! | G18-l | snackbar 取景框判据 | 「更靠近框色而不是画布」 | 条自身的抗锯齿底行覆盖率实测 0.5008(余量 0.0008),可能被算成框 ⇒ **误报方向**(不是假绿);加了「框高 96」之后触发概率下降 | 是(改用精确色 + 容差,或自绘取景框) | //! //! ## 文件地图 //! diff --git a/crates/app-iced/src/components/feedback/probe.rs b/crates/app-iced/src/components/feedback/probe.rs index bf7e4bd..bd88fe2 100644 --- a/crates/app-iced/src/components/feedback/probe.rs +++ b/crates/app-iced/src/components/feedback/probe.rs @@ -275,8 +275,9 @@ fn check_snackbar(shot: &Shot, ui: &Ui) { if !(near(width, 300) || near(width, 344)) { continue; } - // 1px 描边常落在半像素上(两行各 ~50% 覆盖),精确色匹配抓不住; - // 用「更靠近框色而不是画布」判(这一带除了框没有别的灰线)。 + // 1px 描边常落在半像素上(两行各 ~50% 覆盖),精确色匹配抓不住 ⇒ 用「更靠近框色 + // 而不是画布」判。但条自身(深色)比画布**更接近**框色,所以还要一条:这一行的 + // 框色连续段必须**比条本身更宽**(框横贯整个宿主),否则会认成条自己(实测过:差值 1)。 let distance = |pixel: [u8; 3], target: [u8; 3]| -> u32 { pixel .iter() @@ -284,19 +285,44 @@ fn check_snackbar(shot: &Shot, ui: &Ui) { .map(|(a, b)| u32::from(a.abs_diff(b))) .sum() }; + let surface_rgb = rgb(surface); + let frame_run = |y: usize| -> usize { + let mut longest = 0; + let mut current = 0; + for x in 0..shot.width { + let pixel = shot.at(x, y); + if distance(pixel, frame) < distance(pixel, surface_rgb) { + current += 1; + longest = longest.max(current); + } else { + current = 0; + } + } + longest + }; + // 框的横贯长度 > 条宽 + 10 ⇒ 只有框那一两行满足 + let is_frame_row = |y: usize| frame_run(y) >= width + 10; + let frame_row = ((bottom + 1)..shot.height) - .find(|y| { - (0..shot.width) - .filter(|x| { - let pixel = shot.at(*x, *y); - distance(pixel, frame) < distance(pixel, rgb(surface)) - }) - .count() - >= 200 - }) + .find(|y| is_frame_row(*y)) .unwrap_or_else(|| { panic!("D:窄条下方没找到宿主取景框(条 y {top}..{bottom},宽 {width})") }); + let frame_top_row = (0..*top) + .rev() + .find(|y| is_frame_row(*y)) + .unwrap_or_else(|| panic!("D:窄条上方没找到宿主取景框(条顶 y {top})")); + // 复审 P2-1:宿高回退到 72(上下留白相等)时这条会红。 + assert!( + (*top as i64 - frame_top_row as i64 - 36).abs() <= 1, + "D:贴底 ⇒ 框顶到条顶应为 36(96 − 48 − 12),实测 {}", + *top as i64 - frame_top_row as i64 + ); + let frame_height = frame_row - frame_top_row + 1; + assert!( + frame_height.abs_diff(96) <= 1, + "D:取景框高应 96(宿主高),实测 {frame_height}" + ); margins.push(frame_row - bottom); } assert!( diff --git a/crates/app-iced/src/components/feedback/probe/circular.rs b/crates/app-iced/src/components/feedback/probe/circular.rs index b675754..42e7a7a 100644 --- a/crates/app-iced/src/components/feedback/probe/circular.rs +++ b/crates/app-iced/src/components/feedback/probe/circular.rs @@ -1,59 +1,158 @@ //! 环形进度的渲染层判据(`probe` 的子模块,`inputs/probe/` 的同一先例)。 //! -//! 量两件事(`research/12b` §12.3): +//! ## 判据:六个**固定窗**逐个测存在性与尺寸 //! -//! - 环的**外接框 40(±1)** —— 直径是 A1 `CircularProgressIndicatorTokens.Size`; -//! - 环的外接框**四角无墨** —— 真圆,不是圆角方。 +//! gallery 里这一排环在**固定 x 栅格**上:左缘 `24 + 56·i`(环宽 40、间距 16),共 6 个 +//! (0% / 25% / 50% / 75% / 100% / ind 0.30)。判据是「每个窗里都必须有一圈 +//! **40(±2)×40(±2)** 的墨迹(轨道 ∪ 激活色的并集)」: +//! +//! - 少一个环 → 该窗没有墨迹 → 红; +//! - 某个环被画小了(例如 20×20)→ 该窗墨迹的外接框变小 → 红; +//! - 0% 那格只有轨道、没有激活色 → 用「并集」判,不把「没有激活色」当失败。 +//! +//! 早先用「主色连通簇数」判(只要求非空):把 5 个环涂掉仍然绿(复审 r3 P2-3 的反证), +//! 所以 G18-i 的处置从「不修」改成**已用固定窗收口**。簇数仍在日志里打印供复核。 //! //! 不定态的**节奏**(4 圈/周期、跨周期不跳变)不在这里量:那是时间的函数,截图只有一帧, //! 判据在 `tests/progress.rs::circular_indeterminate_rotation_is_four_turns_and_continuous`。 //! -//! ## 欠账 G18-i:没有做「逐环断言」 -//! -//! 复审 G 建议把环数下限收到 6 个里的 ≥5。实测做不到稳:一屏 6 个环里只有 3~4 个能被 -//! 「精确色」连通块干净命中 —— 25% 环是四分之一弧(外接框 21×40)、100% 环首尾相接处被 -//! 抗锯齿切开,簇数随相位在 4~6 之间跳(实测 phase 0.30 为 4 簇)。按簇数断言会把「相位换了」 -//! 误报成「环丢了」。所以只保留「至少一个 40×40 真圆环 + 四角无墨」,**逐环登记为 G18-i** -//! (要稳:gallery 把 6 个环摆成固定 x 栅格,或改自绘 Widget 后按几何断言)。 +//! 欠账 **G18-l**(登记不修):`snackbar` 的「取景框」判据会把条自身的抗锯齿底行算成框 +//! (覆盖率 0.5008,余量 0.0008),方向是**误报**不是假绿;本轮给 snackbar 加了「框高 = 96」 +//! 之后触发概率下降,但那 0.0008 的余量仍在。 +use iced::Color; + +use super::super::pixel::{blobs, coverage, dims, rgb, BBox, Shot}; use unlucky_design::color::Role; -use super::super::pixel::{blobs, dims, matches, near, rgb, Shot}; use crate::theme::Ui; /// 环直径(A1 `CircularProgressIndicatorTokens.Size = 40`)。 const DIAMETER: usize = 40; +/// 环排栅格的左缘(页内边距 24)。 +const RING_X0: usize = 24; +/// 栅格间距(40 环 + 16 = `Space::X4`)。 +const RING_PITCH: usize = 56; +/// 窗数(0 / 25 / 50 / 75 / 100 / ind)。 +const RINGS: usize = 6; -/// 环形那一排的判据。 +/// 某个像素算不算「这块墨」(覆盖率 ≥50%,与 `pixel::runs` 同一口径)。 +fn is_ink(pixel: [u8; 3], fill: Color, under: Color) -> bool { + coverage(pixel, rgb(fill), rgb(under)) >= 0.5 +} + +/// 环形那一排的判据:逐窗测「有没有一圈 40×40 的墨迹」。 pub(super) fn check(shot: &Shot, ui: &Ui) { let active = ui.color(Role::Primary); - let rings: Vec<_> = blobs(shot, active, 30) - .into_iter() - .filter(|bbox| near(bbox.width(), DIAMETER) && near(bbox.height(), DIAMETER)) - .collect(); - assert!( - !rings.is_empty(), - "12.3:没找到 40(±1) 的环形激活层;候选:{}", - dims(&blobs(shot, active, 10)) - ); - for ring in &rings { - for (x, y) in [ - (ring.left, ring.top), - (ring.right, ring.top), - (ring.left, ring.bottom), - (ring.right, ring.bottom), - ] { - assert!( - !matches(shot.at(x, y), rgb(active)), - "12.3:环必须是真圆(外接框四角不该有墨)@ ({x},{y})" - ); + let track = ui.color(Role::SecondaryContainer); + let surface = ui.color(Role::Surface); + + // 环带的 y:以第一个窗(0% 环,只有轨道色)的**圆环签名**定位 —— 中间那几十行在窗的 + // 左右两侧都有墨迹;再向上/下各扩到「窗内还有墨」为止(把圆顶/圆底那两行收回来)。 + // + // 为什么不按「每行墨点数 4..20」判:圆顶那一行笔画横跨 ~20~28 px,会落在区间外, + // 按连续段会把 40 高的带子切成 32 行;为什么不按「首行..末行」:深色档里正文的抗锯齿 + // 像素也会落在轨道色的混色线上,跨度会扩到 274 行(两种都实测过)。 + let ink_count = |y: usize, from: usize, to: usize| -> usize { + (from..to) + .filter(|x| is_ink(shot.at(*x, y), track, surface)) + .count() + }; + let left = RING_X0; + let right = RING_X0 + DIAMETER; + + // 环带 = 第一个窗里「有墨」的**连续行组**中、高度 ≈40 的那一组。 + // + // 走过的两条弯路都实测过: + // ①「每行墨点 4..20」→ 圆顶那一行笔画横跨 ~20~28 px,被排除,带子切成 32 行; + // ②「左右两侧都有墨」→ 进度条那一行也满足(通栏),会认成 4 行高的假带。 + // 连续行组 + 高度窗口同时把「进度条」(4~5 行)与「正文行」排除在外。 + let mut runs: Vec<(usize, usize)> = Vec::new(); + let mut current: Option<(usize, usize)> = None; + for y in 0..shot.height { + match (ink_count(y, left, right) > 0, current) { + (true, None) => current = Some((y, y)), + (true, Some((top, _))) => current = Some((top, y)), + (false, Some(run)) => { + runs.push(run); + current = None; + } + (false, None) => {} } } + if let Some(run) = current { + runs.push(run); + } + let (top, bottom) = runs + .iter() + .copied() + .find(|(top, bottom)| (DIAMETER - 6..=DIAMETER + 6).contains(&(bottom - top + 1))) + .unwrap_or_else(|| panic!("没找到高约 {DIAMETER} 的环形带;窗口 0 的连续行组:{runs:?}")); - let clusters = blobs(shot, active, 10).len(); + let mut report = Vec::new(); + for index in 0..RINGS { + let left = RING_X0 + RING_PITCH * index; + let right = left + DIAMETER - 1; + let mut ink: Option = None; + for y in top..=bottom { + for x in left..=right { + let pixel = shot.at(x, y); + if is_ink(pixel, active, surface) || is_ink(pixel, track, surface) { + match &mut ink { + Some(found) => found.include(x, y), + None => { + ink = Some(BBox { + left: x, + right: x, + top: y, + bottom: y, + }) + } + } + } + } + } + let ink = ink.unwrap_or_else(|| { + panic!("第 {index} 个窗(x {left}..{right})里没有任何环墨迹:环丢了") + }); + assert!( + (DIAMETER - 2..=DIAMETER + 2).contains(&ink.width()) + && (DIAMETER - 2..=DIAMETER + 2).contains(&ink.height()), + "第 {index} 个窗(x {left}..{right})里的环应 {DIAMETER}×{DIAMETER}(±2),实测 {}×{}", + ink.width(), + ink.height() + ); + assert!( + ink.left >= left && ink.right <= right, + "第 {index} 个窗的墨迹越界:{}..{} 不在 {left}..{right}", + ink.left, + ink.right + ); + report.push(format!("{index}:{}×{}", ink.width(), ink.height())); + } + + // 激活色(弧)必须出现在 1.. 号窗里(0% 那格本来就只有轨道)。 + for index in 1..RINGS { + let left = RING_X0 + RING_PITCH * index; + let count: usize = (top..=bottom) + .map(|y| { + (left..left + DIAMETER) + .filter(|x| is_ink(shot.at(*x, y), active, surface)) + .count() + }) + .sum(); + assert!( + count > 40, + "第 {index} 个窗(x {left}..)的激活色弧只有 {count} px:环在但弧没画" + ); + } + + let clusters = blobs(shot, active, 10); eprintln!( - "[feedback-probe] circular:40(±1) 环 {} 个 {}(主色连通簇共 {clusters} 个,见 G18-i)", - rings.len(), - dims(&rings) + "[feedback-probe] circular:固定窗 {RINGS} 个逐窗 [{ }](y {top}..{bottom})· \ + 主色连通簇 {} 个(仅打印,判据是窗)", + report.join(" / "), + clusters.len() ); + let _ = dims(&clusters); } diff --git a/crates/app-iced/src/components/feedback/tests.rs b/crates/app-iced/src/components/feedback/tests.rs index fafa87d..7ecc203 100644 --- a/crates/app-iced/src/components/feedback/tests.rs +++ b/crates/app-iced/src/components/feedback/tests.rs @@ -62,6 +62,26 @@ fn dialog_geometry_sits_inside_the_official_bounds() { ); } +/// 复审 P2-2:`max(280)` 那条支以前一次都没被执行过(只有定义 + 一个调用点)。 +/// +/// 抓得住:有人把下限去掉(200 → 200)、或把 `min/max` 顺序写反(200 → 480 之外的值)。 +/// 抓不住:渲染时是否真的画了 280(那需要真窗口;gallery 的 360 那格由 probe 的逐卡宽度兜)。 +#[test] +fn dialog_width_respects_the_official_minimum() { + let geometry = DialogGeometry::of(&ui(SchemeFlavor::Light)); + assert_eq!( + geometry.effective_width(200.0), + 280.0, + "可用宽 200 → 仍画官方下限 280" + ); + assert_eq!(geometry.effective_width(360.0), 360.0, "窄宿主取可用宽"); + assert_eq!( + geometry.effective_width(800.0), + 480.0, + "宽宿主夹到项目值 480" + ); +} + /// 7.1 / 7.3 / 7.6:圆角 28、内距 24 / 16 / 24、动作 8 / 12。 #[test] fn dialog_padding_and_action_gaps_are_the_tokens() {