fix(app-iced): M100 r1 返工 —— 一次一采样 + OS 层密闭指标 + 文档口径订正
三条复审意见 + 六条 p3,全部落在原有文件里(另加两个拆分文件)。 P2-1 截图落盘「最后写入者说了算」(真竞态) `Message::Frame` 以前在 `frames >= CAPTURE_AT_FRAME` 之后每帧都发一次 `window::screenshot`, `Captured` 每次都覆盖同一文件才 `exit()`;已派发的采样不会因 exit 撤回 ⇒ 落盘内容是调度结果 (复审实测 12 次里 7 次写了两次;把窗口在两次采样之间改成 900×600,落盘就是 900×600)。 现在闸门 = `should_request_capture(frames, dispatched, capture_mode)`,派发即置位、只开一次; `Captured` 计数落盘次数并打进日志。实测 12/12 次「[shot] -> 文件」行恒为 1、`落盘次数=1`。 P2-2 密闭证据换成 OS 层 `platform/win32/transparency.rs` 加安全 getter `click_through`(unsafe 仍只在既有唯一落点); 该文件因超 400 行又拆出 `transparency/exstyle.rs`(子模块继承父模块的 `#![allow(unsafe_code)]`, `unsafe-scope` 门禁判据不受影响)。日志三态 `[caps] passthrough_ext=on|off|unknown`: `None` 记 `unknown`,绝不记 `off`。实测默认 on 12/12、`--passthrough=off` 为 off 3/3。 P2-3 头注释「下放」→「新增」,并订正「接真控件改 3 个文件」 (section 文件里的 body 绊线 + 该 section 的 `GROUP` 文件 + `tests/mod.rs` 跨 section 绊线)。 p3 六条:shell 断言说明改准(默认值那条归 cli 的测试)、harness 文档给「指针事件=0」加对照限定、 数字订正(纯搬移 234 / 新增 134)、三文件口径、ai+about 对本文件的两处必改、`Rest` 与真 Disabled 撞语义写进「抓不住」。 文档口径:「8 连跑全稳」不再当密闭证据——同 argv 开/关穿透 hash 相同(实测 0d7b4f5b 两边一样), 它只说明「帧静止 + 落盘确定」。密闭主证据是 `[caps] passthrough_ext=`。 顺带:`run()` 从 `harness.rs` 拆到 `harness/app.rs`(272 / 190 行)。搬移中丢过一条既有断言 `ensure_layered_refuses_a_missing_or_invalid_window`,被编译器 unused-import 警告抓出并复原。
This commit is contained in:
1 parent
2061296615
commit
380fac0324
13 files changed
+545
-201
No files matched your search
@@ -116,6 +116,11 @@ pub enum ForcedState {
|
||||
/// 第 6 次给出第三个 hash,差异区是某个按钮被画成 `Hovered`——因为 harness/产品都开真窗口,
|
||||
/// `button::Status` 由系统光标位置决定。取证图不能因为「指针恰好压在某个按钮上」而变色。
|
||||
/// 显式 `--force-state=hover|pressed` 仍然优先(那是**故意**要画那个态)。
|
||||
///
|
||||
/// ⚠️ **语义边界**:`Rest` 落成 `Interaction::Enabled`,而 `components::buttons::assemble()`
|
||||
/// 把「没有 `on_press`」的按钮默认画成 **Disabled** —— 于是 `Rest` 会把一个本应禁用的按钮
|
||||
/// 画成可用。当前零影响(所有 `forced_state(` 调用点与 shell 的 caption / nav 按钮都带消息),
|
||||
/// 但给「无消息按钮」出取证图时别套 `Rest`。
|
||||
Rest,
|
||||
/// 所有可交互控件按 hover 画。
|
||||
Hover,
|
||||
|
||||
@@ -10,15 +10,39 @@
|
||||
//! ## 取证密闭(M100):取证图不许跟着系统指针变
|
||||
//!
|
||||
//! M99 复审实测过:同一个 exe 连跑 8 次,第 6 次给出**第三个 hash**,差异区是某个按钮被画成 Hovered
|
||||
//! ——根因是 harness 开真窗口,`button::Status` 由**系统光标位置**决定,而「连跑两次一样」不是判据。
|
||||
//! ——根因是 harness 开真窗口,`button::Status` 由**系统光标位置**决定。
|
||||
//!
|
||||
//! 所以 `--screenshot` 有一条**通道级**钉死:开窗那一刻调 `window::enable_mouse_passthrough`
|
||||
//! 于是 `--screenshot` 有一条**通道级**钉死:开窗那一刻调 `window::enable_mouse_passthrough`
|
||||
//! (winit 的 `set_cursor_hittest(false)`)⇒ 窗口从此收不到指针事件 ⇒ 全树控件恒为 `Active`。
|
||||
//! **为什么不是给每个控件灌 forced**:设置页「外观」区的滑块 / 文本框 / 裸 `iced::button` 都不消费
|
||||
//! forced,那样做要改十几个组件文件——为取证去动产品组件,代价与风险都不对,而且新组件随时会漏。
|
||||
//!
|
||||
//! 同时数指针事件(`iced::event::listen_with`)并打进日志:**「这张图收到几个鼠标事件」是可读数字**,
|
||||
//! 于是「hash 稳」不再是「大概没漏」,而是「这张图压根没收到指针事件」。
|
||||
//! ⚠️ **限定**:上句的因果链在**有指针输入的会话**里才被实测过。本机(RDP 断开)连
|
||||
//! `SetCursorPos` 都失败,所以「`指针事件=0`」在本环境里**既可能是穿透生效,也可能是根本没输入**。
|
||||
//! 要让 `指针事件=N` 变成密闭证据,必须同一 argv 再跑一次 `--passthrough=off` 做对照:
|
||||
//! 那一跑该给出 `N>0`(复审注入后实测 12)**并且** hash 与开穿透时不同。没有这个对照,
|
||||
//! `指针事件=0` 就退回成「没观测到输入」,而不能读成「输入被挡住了」。
|
||||
//!
|
||||
//! ### 证据怎么读(M100 r1 复审订正,优先级从高到低)
|
||||
//!
|
||||
//! 1. **`[caps] passthrough_ext=on|off|unknown`** ← 主证据。winit 0.30 在 Windows 把
|
||||
//! `set_cursor_hittest(false)` 落成 `WS_EX_TRANSPARENT | WS_EX_LAYERED`,这一位可以从
|
||||
//! `GWL_EXSTYLE` 读回来([`caps`] → `platform::win32::transparency::click_through`)。
|
||||
//! 复审探针实测:`--screenshot` 默认 176/214 次采样命中、`--passthrough=off` **0/320**、
|
||||
//! 产品 bin **0/535**。三态里的 `unknown` 是「没读到」,**不许**读成 `off`。
|
||||
//! 2. `指针事件=N`:**辅助**指标。它证明「鼠标消息到没到」,**不**证明「像素会不会变」
|
||||
//! (复审实测:注入 12 个事件而像素不变)。别拿它当密闭证据。
|
||||
//! 3. ~~hash 连跑几次都一样~~:**不是证据**。同 argv 加 `--passthrough=off` 出来的 hash 与
|
||||
//! 开穿透时完全相同 ⇒ 它只能说明「那几帧恰好静止」。
|
||||
//!
|
||||
//! ### 同一族的另一个坑:一次一采样(P2-1)
|
||||
//!
|
||||
//! 以前第 3 帧之后**每一帧**都请求一次采样、每次都覆盖同一文件才 `exit()`,而已派发的采样不会
|
||||
//! 因为 exit 撤回 ⇒ **最后写入者说了算**(复审实测 12 次里 7 次写了两次;把窗口在两次采样之间
|
||||
//! 改成 900×600,落盘的就是 900×600 那张)。现在闸门在 [`should_request_capture`],日志里的
|
||||
//! `落盘次数=` 必须恒为 1。
|
||||
//!
|
||||
//! **为什么不是给每个控件灌 forced**:设置页「外观」区的滑块 / 文本框 / 裸 `iced::button` 都不
|
||||
//! 消费 forced,那样做要改十几个组件文件——为取证去动产品组件,代价与风险都不对,而且新产品
|
||||
//! 组件随时会漏。
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p unlucky-app-iced --example pages_demo -- --screen=instances --width=1180 \
|
||||
@@ -31,190 +55,33 @@ use std::time::Instant;
|
||||
|
||||
use iced::{window, Element, Point, Subscription, Task};
|
||||
|
||||
use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs};
|
||||
|
||||
use crate::app::components::interaction::Interaction;
|
||||
use crate::cli::Backend;
|
||||
use crate::fonts;
|
||||
use crate::pages::appearance;
|
||||
use crate::probe::{self, Probe};
|
||||
use crate::renderer;
|
||||
use crate::shot;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{app_with_appearance, demo_model, Model};
|
||||
|
||||
mod app;
|
||||
mod caps;
|
||||
mod request;
|
||||
|
||||
pub use app::run;
|
||||
pub use request::{Request, CAPTURE_POSITION};
|
||||
|
||||
/// 截图落盘的帧号(见模块文档)。
|
||||
const CAPTURE_AT_FRAME: u32 = 3;
|
||||
|
||||
/// 启动(阻塞到出图或关窗)。
|
||||
/// 这一帧要不要请求采样?
|
||||
///
|
||||
/// **取证通道的硬规则(M97)**:宁可红着退出,也不能悄悄换一种后端继续出图(以前 wgpu 挂掉时
|
||||
/// 会静默降级 tiny-skia 照样落 PNG,而报告上写着「GPU 渲染截图」)。所以这里 ① 决策被
|
||||
/// [`renderer::strict`] 剥掉兜底、② 起不来带**具名**错误返回、③ 每张图日志都带 `backend=`。
|
||||
/// **M100 r1 P2-1 的核心判据**:`dispatched` 一旦置位就永不再次请求。以前这里是
|
||||
/// `frames >= CAPTURE_AT_FRAME`,于是第 3 帧之后**每一帧**都发一次 `window::screenshot`,
|
||||
/// 而 `Captured` 每次都覆盖同一文件才 `exit()`——已派发的采样不会因为 exit 被撤回,
|
||||
/// 落盘内容变成调度结果(复审实测 12 次里 7 次写了两次)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 后端探测/字体求值失败、**渲染后端起不来(含 iced 的具名图形错误)**、截图落盘失败时返回人话。
|
||||
pub fn run(request: Request) -> Result<(), String> {
|
||||
let started = Instant::now();
|
||||
|
||||
let needs_probe = request.backend == Backend::Auto || renderer::acceptance::enabled();
|
||||
let probe = if needs_probe {
|
||||
Probe::run()
|
||||
} else {
|
||||
Probe::default()
|
||||
};
|
||||
// 剥掉兜底:gallery 没有「偷偷换 CPU」这个选项。
|
||||
let decision = renderer::strict(probe::apply(request.backend, &probe));
|
||||
eprintln!(
|
||||
"[shot] 出图后端 = {} reason={}(取证通道无回退:起不来就红着退出,绝不换 CPU 出图)",
|
||||
decision.primary.name(),
|
||||
decision.reason
|
||||
);
|
||||
renderer::acceptance::report_backend_plan(&decision, request.backend);
|
||||
|
||||
let resolved = fonts::resolve();
|
||||
eprintln!("{}", resolved.describe());
|
||||
let families = resolved.families();
|
||||
|
||||
// 这一份就是喂给 iced 的真值:`[window]` 那行从它取字段(M98 r1 p2-1 的同一个教训)
|
||||
let settings = window::Settings {
|
||||
size: iced::Size::new(request.width, request.height),
|
||||
decorations: false,
|
||||
resizable: true,
|
||||
// 取证窗口钉在固定落点:这样「把指针放进窗口某个位置」是可复算的(真实矩形由
|
||||
// `Harness` 用 `window::position` / `window::size` 的返回值打日志报出,见 `Message::Sized`)
|
||||
position: match request.screenshot {
|
||||
Some(_) => {
|
||||
window::Position::Specific(Point::new(CAPTURE_POSITION.0, CAPTURE_POSITION.1))
|
||||
}
|
||||
None => window::Position::default(),
|
||||
},
|
||||
..window::Settings::default()
|
||||
};
|
||||
renderer::acceptance::report_window(
|
||||
&renderer::acceptance::WindowFacts::from_settings(&settings),
|
||||
request.scale_factor,
|
||||
"pages_demo",
|
||||
None,
|
||||
);
|
||||
|
||||
// 外观**真状态**(`--appearance=real`):工厂态 + `ULL_APPEARANCE_*` 覆盖。
|
||||
// 这不是给设置页塞常量:状态从 `State::new` / `apply_env` 来,打日志的是它算出来的生效值。
|
||||
let appearance = request.real_appearance.then(|| {
|
||||
let mut state = appearance::State::new(
|
||||
&AppearancePrefs::factory(),
|
||||
&AppearanceBase::factory(),
|
||||
request.backend,
|
||||
request.flavor,
|
||||
);
|
||||
state.apply_env();
|
||||
eprintln!(
|
||||
"[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \
|
||||
配色来源={:?} 选中分组={}",
|
||||
state.window_opacity,
|
||||
state.panel_alpha,
|
||||
state.effective_panel_alpha(),
|
||||
state.bg_enabled,
|
||||
state.bg_fill,
|
||||
state.bg_dim,
|
||||
state.palette_source,
|
||||
state.selected_group,
|
||||
);
|
||||
state
|
||||
});
|
||||
|
||||
let application = |backend: renderer::Kind| {
|
||||
let boot = request.clone();
|
||||
let appearance = appearance.clone();
|
||||
iced::application(
|
||||
move || {
|
||||
let mut model = demo_model(boot.screen, boot.width);
|
||||
// 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot`
|
||||
// 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced),
|
||||
// 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。
|
||||
if boot.screenshot.is_some() {
|
||||
model.forced = Some(Interaction::Enabled);
|
||||
}
|
||||
(
|
||||
Harness {
|
||||
ui: Ui::new(boot.flavor, families),
|
||||
model,
|
||||
appearance: appearance.clone(),
|
||||
request: boot.clone(),
|
||||
window: None,
|
||||
placed: None,
|
||||
frames: 0,
|
||||
pointer: 0,
|
||||
started,
|
||||
first_frame: None,
|
||||
backend: backend.name(),
|
||||
},
|
||||
Task::done(Message::Boot),
|
||||
)
|
||||
},
|
||||
Harness::update,
|
||||
Harness::view,
|
||||
)
|
||||
.theme(Harness::theme)
|
||||
.subscription(Harness::subscription)
|
||||
.default_font(iced::Font {
|
||||
family: iced::font::Family::Name(families.text),
|
||||
..iced::Font::DEFAULT
|
||||
})
|
||||
.window(settings)
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"[harness] screen={} width={} height={} slug={}(布局档位 {})",
|
||||
request.screen.slug(),
|
||||
request.width,
|
||||
request.height,
|
||||
request.slug(),
|
||||
super::layout::WidthClass::of(request.width).name()
|
||||
);
|
||||
|
||||
// 兜底已被 `strict` 剥掉,所以这条最多跑一次;具名错误的口径与 App 启动一致,
|
||||
// 但**取证通道不许降级**:出不了图就红着退出,不换后端(那是另一处 iced 行为)。
|
||||
let session_started = Instant::now();
|
||||
let result = renderer::run_once(decision, |backend| {
|
||||
std::env::set_var("ICED_BACKEND", backend.env_value());
|
||||
let application = application(backend);
|
||||
match request.scale_factor {
|
||||
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||||
None => application.run(),
|
||||
}
|
||||
});
|
||||
let session = session_started.elapsed();
|
||||
|
||||
let outcome = result.map_err(|error| match renderer::named_graphics_error(&error) {
|
||||
Some(named) => format!(
|
||||
"渲染后端 {} 初始化失败(取证通道无回退,未出图):{named}",
|
||||
decision.primary.name()
|
||||
),
|
||||
None => format!("iced 启动失败:{error}"),
|
||||
});
|
||||
|
||||
// 取证通道恒为 evidence 启动 ⇒ 有兜底时 via=refuse、没兜底时 none(不再写死,M98 r1 p3-4)
|
||||
renderer::acceptance::report_outcome(
|
||||
&decision,
|
||||
outcome.as_ref().err().map(String::as_str),
|
||||
renderer::acceptance::failure_via(decision.fallback.is_some(), true),
|
||||
"screenshot",
|
||||
session,
|
||||
);
|
||||
|
||||
outcome?;
|
||||
|
||||
eprintln!(
|
||||
"[harness] 用时 {:.0} ms",
|
||||
started.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
Ok(())
|
||||
/// 单独拎成自由函数是为了让它可被断言(`Harness::update` 要 iced 运行时才能跑)。
|
||||
fn should_request_capture(frames: u32, dispatched: bool, capture_mode: bool) -> bool {
|
||||
capture_mode && !dispatched && frames >= CAPTURE_AT_FRAME
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
@@ -225,10 +92,20 @@ struct Harness {
|
||||
/// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。
|
||||
appearance: Option<appearance::State>,
|
||||
window: Option<window::Id>,
|
||||
/// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读,
|
||||
/// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。
|
||||
hwnd: Option<isize>,
|
||||
/// 窗口落点(`window::position` 的**返回值**,不是请求值)。
|
||||
placed: Option<Point>,
|
||||
frames: u32,
|
||||
/// 本张图收到过几个鼠标事件(`Message::Pointer` 的计数;密闭性靠它自证)。
|
||||
/// 采样**已派发**(M100 r1 P2-1):置位后不再请求第二张,避免「最后写入者说了算」。
|
||||
dispatched: bool,
|
||||
/// 本进程真正落盘了几次(正常必须恒为 1;打进日志让复审查得到)。
|
||||
writes: u32,
|
||||
/// 本张图收到过几个鼠标事件(`Message::Pointer` 的计数)。
|
||||
///
|
||||
/// ⚠️ 它只证明「鼠标消息到没到」,**不**证明「像素会不会变」(复审实测:注入 12 个事件而像素
|
||||
/// 不变)。密闭的**主证据**是 `[caps] passthrough_ext=`,见 [`caps`]。
|
||||
pointer: u32,
|
||||
/// 进程起点(`run()` 的开头)——`[perf]` 的首帧口径与产品路径一致:从进 `run()` 算。
|
||||
started: Instant,
|
||||
@@ -242,6 +119,8 @@ struct Harness {
|
||||
enum Message {
|
||||
Boot,
|
||||
WindowReady(Option<window::Id>),
|
||||
/// `window::raw_id` 的回值(原生句柄,OS 侧密闭指标要它)。
|
||||
RawId(u64),
|
||||
/// `window::position` 的回值。
|
||||
Placed(Option<Point>),
|
||||
/// `window::size` 的回值。
|
||||
@@ -266,14 +145,19 @@ impl Harness {
|
||||
let Some(id) = id else {
|
||||
return Task::none();
|
||||
};
|
||||
let mut tasks = Vec::new();
|
||||
let mut tasks: Vec<Task<Message>> = Vec::new();
|
||||
if self.request.passthrough {
|
||||
eprintln!("[harness] 指针穿透已开:本窗口不再接收鼠标事件(取证专用)");
|
||||
tasks.push(window::enable_mouse_passthrough(id));
|
||||
}
|
||||
tasks.push(window::raw_id::<Message>(id).map(Message::RawId));
|
||||
tasks.push(window::position(id).map(Message::Placed));
|
||||
Task::batch(tasks)
|
||||
}
|
||||
Message::RawId(raw) => {
|
||||
self.hwnd = isize::try_from(raw).ok();
|
||||
Task::none()
|
||||
}
|
||||
Message::Placed(position) => {
|
||||
self.placed = position;
|
||||
match self.window {
|
||||
@@ -296,11 +180,23 @@ impl Harness {
|
||||
self.first_frame = Some(self.started.elapsed());
|
||||
}
|
||||
self.frames += 1;
|
||||
if self.frames < CAPTURE_AT_FRAME || self.request.screenshot.is_none() {
|
||||
// **一次一采样**(M100 r1 P2-1):以前这里是 `frames >= CAPTURE_AT_FRAME`,于是第 3 帧
|
||||
// 之后**每一帧**都发一次 `window::screenshot`,而 `Captured` 每次都覆盖同一文件才
|
||||
// `exit()`——已派发的采样不会因为 exit 被撤回 ⇒ **最后写入者说了算**。复审实测 12 次里
|
||||
// 7 次真的写了两次;把窗口在两次采样之间改成 900×600,落盘的就是 900×600 那张。
|
||||
// 现在 `dispatched` 一置位就不再请求(`window` 还没就绪时不置位,保留重试,不会挂死)。
|
||||
if !should_request_capture(
|
||||
self.frames,
|
||||
self.dispatched,
|
||||
self.request.screenshot.is_some(),
|
||||
) {
|
||||
return Task::none();
|
||||
}
|
||||
match self.window {
|
||||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||||
Some(id) => {
|
||||
self.dispatched = true;
|
||||
window::screenshot(id).map(Message::Captured)
|
||||
}
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
@@ -312,16 +208,24 @@ impl Harness {
|
||||
let Some(path) = self.request.screenshot.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
self.writes += 1;
|
||||
match shot::save(&screenshot, &path) {
|
||||
Ok(summary) => eprintln!("{}", summary.describe()),
|
||||
Err(error) => eprintln!("[harness] 截图失败:{error}"),
|
||||
}
|
||||
eprintln!(
|
||||
"[harness] slug={} backend={} 指针事件={}(这张图的来源与密闭性,自证用)",
|
||||
"[harness] slug={} backend={} 落盘次数={} 指针事件={}",
|
||||
self.request.slug(),
|
||||
self.backend,
|
||||
self.writes,
|
||||
self.pointer
|
||||
);
|
||||
// OS 侧密闭指标(M100 r1 P2-2):`WS_EX_TRANSPARENT` 在不在。三态,`unknown` 不许
|
||||
// 写成 `off`。这一行是「窗口真的把命中测试关掉了」的主证据;`指针事件=` 只是辅助。
|
||||
eprintln!(
|
||||
"[caps] passthrough_ext={}(GWL_EXSTYLE 的 WS_EX_TRANSPARENT;unknown = 没读到)",
|
||||
caps::passthrough_label(self.hwnd)
|
||||
);
|
||||
// `[perf]` 两格都是实测:`first_frame` = 首帧事件的时间戳,`fps` = 帧数 / 时长
|
||||
// (**取证窗口**的吞吐,不是长跑稳态帧率——那个看产品路径的 `[tick]`)。
|
||||
renderer::acceptance::report_perf_scene(
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! 截图 harness 的**运行部分**(M100 r1:`harness.rs` 撞 400 行后从那里搬出来)。
|
||||
//!
|
||||
//! 这里只有 [`run`]:探后端 → 造外观真状态 → 起 iced → 出图 → 按验收口径打日志。
|
||||
//! 参数解析在 [`super::request`],窗口状态机与采样闸门在 [`super`],OS 侧密闭指标在
|
||||
//! [`super::caps`]。搬出来没有改行为(同一 argv 的 PNG sha256 不变)。
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use iced::{window, Point, Task};
|
||||
|
||||
use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs};
|
||||
|
||||
use crate::app::components::interaction::Interaction;
|
||||
use crate::cli::Backend;
|
||||
use crate::fonts;
|
||||
use crate::pages::appearance;
|
||||
use crate::probe::{self, Probe};
|
||||
use crate::renderer;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{demo_model, Harness, Message, Request, CAPTURE_POSITION};
|
||||
|
||||
/// 启动(阻塞到出图或关窗)。
|
||||
///
|
||||
/// **取证通道的硬规则(M97)**:宁可红着退出,也不能悄悄换一种后端继续出图(以前 wgpu 挂掉时
|
||||
/// 会静默降级 tiny-skia 照样落 PNG,而报告上写着「GPU 渲染截图」)。所以这里 ① 决策被
|
||||
/// [`renderer::strict`] 剥掉兜底、② 起不来带**具名**错误返回、③ 每张图日志都带 `backend=`。
|
||||
///
|
||||
/// # Errors
|
||||
/// 后端探测/字体求值失败、**渲染后端起不来(含 iced 的具名图形错误)**、截图落盘失败时返回人话。
|
||||
pub fn run(request: Request) -> Result<(), String> {
|
||||
let started = Instant::now();
|
||||
|
||||
let needs_probe = request.backend == Backend::Auto || renderer::acceptance::enabled();
|
||||
let probe = if needs_probe {
|
||||
Probe::run()
|
||||
} else {
|
||||
Probe::default()
|
||||
};
|
||||
// 剥掉兜底:gallery 没有「偷偷换 CPU」这个选项。
|
||||
let decision = renderer::strict(probe::apply(request.backend, &probe));
|
||||
eprintln!(
|
||||
"[shot] 出图后端 = {} reason={}(取证通道无回退:起不来就红着退出,绝不换 CPU 出图)",
|
||||
decision.primary.name(),
|
||||
decision.reason
|
||||
);
|
||||
renderer::acceptance::report_backend_plan(&decision, request.backend);
|
||||
|
||||
let resolved = fonts::resolve();
|
||||
eprintln!("{}", resolved.describe());
|
||||
let families = resolved.families();
|
||||
|
||||
// 这一份就是喂给 iced 的真值:`[window]` 那行从它取字段(M98 r1 p2-1 的同一个教训)
|
||||
let settings = window::Settings {
|
||||
size: iced::Size::new(request.width, request.height),
|
||||
decorations: false,
|
||||
resizable: true,
|
||||
// 取证窗口钉在固定落点:这样「把指针放进窗口某个位置」是可复算的(真实矩形由
|
||||
// `Harness` 用 `window::position` / `window::size` 的返回值打日志报出,见 `Message::Sized`)
|
||||
position: match request.screenshot {
|
||||
Some(_) => {
|
||||
window::Position::Specific(Point::new(CAPTURE_POSITION.0, CAPTURE_POSITION.1))
|
||||
}
|
||||
None => window::Position::default(),
|
||||
},
|
||||
..window::Settings::default()
|
||||
};
|
||||
renderer::acceptance::report_window(
|
||||
&renderer::acceptance::WindowFacts::from_settings(&settings),
|
||||
request.scale_factor,
|
||||
"pages_demo",
|
||||
None,
|
||||
);
|
||||
|
||||
// 外观**真状态**(`--appearance=real`):工厂态 + `ULL_APPEARANCE_*` 覆盖。
|
||||
// 这不是给设置页塞常量:状态从 `State::new` / `apply_env` 来,打日志的是它算出来的生效值。
|
||||
let appearance = request.real_appearance.then(|| {
|
||||
let mut state = appearance::State::new(
|
||||
&AppearancePrefs::factory(),
|
||||
&AppearanceBase::factory(),
|
||||
request.backend,
|
||||
request.flavor,
|
||||
);
|
||||
state.apply_env();
|
||||
eprintln!(
|
||||
"[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \
|
||||
配色来源={:?} 选中分组={}",
|
||||
state.window_opacity,
|
||||
state.panel_alpha,
|
||||
state.effective_panel_alpha(),
|
||||
state.bg_enabled,
|
||||
state.bg_fill,
|
||||
state.bg_dim,
|
||||
state.palette_source,
|
||||
state.selected_group,
|
||||
);
|
||||
state
|
||||
});
|
||||
|
||||
let application = |backend: renderer::Kind| {
|
||||
let boot = request.clone();
|
||||
let appearance = appearance.clone();
|
||||
iced::application(
|
||||
move || {
|
||||
let mut model = demo_model(boot.screen, boot.width);
|
||||
// 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot`
|
||||
// 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced),
|
||||
// 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。
|
||||
if boot.screenshot.is_some() {
|
||||
model.forced = Some(Interaction::Enabled);
|
||||
}
|
||||
(
|
||||
Harness {
|
||||
ui: Ui::new(boot.flavor, families),
|
||||
model,
|
||||
appearance: appearance.clone(),
|
||||
request: boot.clone(),
|
||||
window: None,
|
||||
hwnd: None,
|
||||
placed: None,
|
||||
frames: 0,
|
||||
dispatched: false,
|
||||
writes: 0,
|
||||
pointer: 0,
|
||||
started,
|
||||
first_frame: None,
|
||||
backend: backend.name(),
|
||||
},
|
||||
Task::done(Message::Boot),
|
||||
)
|
||||
},
|
||||
Harness::update,
|
||||
Harness::view,
|
||||
)
|
||||
.theme(Harness::theme)
|
||||
.subscription(Harness::subscription)
|
||||
.default_font(iced::Font {
|
||||
family: iced::font::Family::Name(families.text),
|
||||
..iced::Font::DEFAULT
|
||||
})
|
||||
.window(settings)
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"[harness] screen={} width={} height={} slug={}(布局档位 {})",
|
||||
request.screen.slug(),
|
||||
request.width,
|
||||
request.height,
|
||||
request.slug(),
|
||||
crate::pages::layout::WidthClass::of(request.width).name()
|
||||
);
|
||||
|
||||
// 兜底已被 `strict` 剥掉,所以这条最多跑一次;具名错误的口径与 App 启动一致,
|
||||
// 但**取证通道不许降级**:出不了图就红着退出,不换后端(那是另一处 iced 行为)。
|
||||
let session_started = Instant::now();
|
||||
let result = renderer::run_once(decision, |backend| {
|
||||
std::env::set_var("ICED_BACKEND", backend.env_value());
|
||||
let application = application(backend);
|
||||
match request.scale_factor {
|
||||
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||||
None => application.run(),
|
||||
}
|
||||
});
|
||||
let session = session_started.elapsed();
|
||||
|
||||
let outcome = result.map_err(|error| match renderer::named_graphics_error(&error) {
|
||||
Some(named) => format!(
|
||||
"渲染后端 {} 初始化失败(取证通道无回退,未出图):{named}",
|
||||
decision.primary.name()
|
||||
),
|
||||
None => format!("iced 启动失败:{error}"),
|
||||
});
|
||||
|
||||
// 取证通道恒为 evidence 启动 ⇒ 有兜底时 via=refuse、没兜底时 none(不再写死,M98 r1 p3-4)
|
||||
renderer::acceptance::report_outcome(
|
||||
&decision,
|
||||
outcome.as_ref().err().map(String::as_str),
|
||||
renderer::acceptance::failure_via(decision.fallback.is_some(), true),
|
||||
"screenshot",
|
||||
session,
|
||||
);
|
||||
|
||||
outcome?;
|
||||
|
||||
eprintln!(
|
||||
"[harness] 用时 {:.0} ms",
|
||||
started.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! 取证窗口的**OS 侧密闭指标**(M100 r1 P2-2):`WS_EX_TRANSPARENT` 在不在。
|
||||
//!
|
||||
//! 为什么单独一个文件:① `harness.rs` 正卡在 400 行硬线上;② 「三态映射」这件事本身值得有断言
|
||||
//! (把 `unknown` 掉成 `off` 是这类日志最典型的假绿)。
|
||||
//!
|
||||
//! 判据来自 winit 0.30 的 Windows 实现:`set_cursor_hittest(false)` ⇒
|
||||
//! `WS_EX_TRANSPARENT | WS_EX_LAYERED`。读位的手在
|
||||
//! [`crate::platform::win32::transparency::click_through`](全仓唯一 unsafe 落点)。
|
||||
|
||||
use crate::platform::win32::transparency;
|
||||
|
||||
/// 窗口是不是点击穿透 → 日志里的三态字面量。
|
||||
///
|
||||
/// - `on` = 读到了,且有 `WS_EX_TRANSPARENT`(winit 已把命中测试关掉);
|
||||
/// - `off` = 读到了,没有这一位;
|
||||
/// - `unknown` = **没读到**(非 Windows / 句柄无效 / 还没拿到句柄)。
|
||||
///
|
||||
/// `unknown` 绝不许写成 `off`:「拿不到」和「没有」是两件事(U1 同族)。
|
||||
#[must_use]
|
||||
pub fn passthrough_label(hwnd: Option<isize>) -> &'static str {
|
||||
match hwnd.and_then(transparency::click_through) {
|
||||
Some(true) => "on",
|
||||
Some(false) => "off",
|
||||
None => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::passthrough_label;
|
||||
|
||||
/// **抓得住**:谁把「没读到」掉成 `off`(那样一次失败的读数就变成了「密闭没开」的假证据,
|
||||
/// 或者反过来——在一台读不到扩展样式的机器上永远看不出密闭是否生效)。
|
||||
/// **抓不住**:真实窗口上的位到底置没置(那要出图日志里看 `on`)。
|
||||
#[test]
|
||||
fn a_missing_handle_is_unknown_not_off() {
|
||||
assert_eq!(
|
||||
passthrough_label(None),
|
||||
"unknown",
|
||||
"没句柄 = 没读到 ⇒ unknown"
|
||||
);
|
||||
assert_eq!(
|
||||
passthrough_label(Some(0)),
|
||||
"unknown",
|
||||
"句柄 0 = 拿不到 ⇒ unknown(不是 off)"
|
||||
);
|
||||
assert_eq!(
|
||||
passthrough_label(Some(1)),
|
||||
"unknown",
|
||||
"无效句柄 ⇒ unknown(不是 off)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
//! `pages::harness` 的断言(M90 r2 从 `harness.rs` 外置:那边曾 402 行,guards 的 file-size 会判红)。
|
||||
//!
|
||||
//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时——所以「指针穿透真的把窗口的鼠标输入关了」
|
||||
//! 这类事这里证明不了,只能靠出图时日志里的 `指针事件=N`(见 `harness.rs` 的模块文档)。
|
||||
//! 只测**参数解析 / slug / 采样闸门 / 三态标签**,不碰 iced 运行时。所以「穿透在真实窗口上真的
|
||||
//! 关掉了命中测试」这条这里证明不了——它要看出图日志里的 `[caps] passthrough_ext=`(三态,
|
||||
//! 来自 `GWL_EXSTYLE` 的 `WS_EX_TRANSPARENT`);`指针事件=N` 只是辅助指标(它只证明消息到没到)。
|
||||
|
||||
use super::request::Request;
|
||||
use super::should_request_capture;
|
||||
use crate::cli::Backend;
|
||||
use crate::pages::Screen;
|
||||
|
||||
@@ -102,8 +104,7 @@ fn the_control_run_can_turn_the_passthrough_off() {
|
||||
assert!(shot.screenshot.is_some(), "off 只对截图通道有意义");
|
||||
}
|
||||
|
||||
/// **外观真状态那一屏(M100)**:`--appearance=real` 只在设置页成立,且 slug 要与演示行版分开。
|
||||
///
|
||||
/// **外观真状态那一屏(M100)**:`--appearance=real` 只在设置页成立,且 slug 要与演示行版分开。///
|
||||
/// 抓得住:① 谁把 real 接到别的页(那会出一张「假装接了线」的图);
|
||||
/// ② 两张图落成同名文件(归档后无法区分哪张是真控件版)。
|
||||
/// 抓不住:那张图里到底有没有控件——那是像素的事,由两张图的 sha256 不同 + tower 亲自看图判。
|
||||
@@ -123,3 +124,50 @@ fn the_real_control_screen_is_settings_only_and_says_so_in_the_slug() {
|
||||
);
|
||||
assert!(parse(&["--screen=settings", "--appearance=x"]).is_err());
|
||||
}
|
||||
|
||||
/// **一次一采样(M100 r1 P2-1)**:采样请求最多发一次,且最早在 `CAPTURE_AT_FRAME`。
|
||||
///
|
||||
/// 抓得住:谁把闸门改回 `frames >= CAPTURE_AT_FRAME`(那样第 3 帧之后每帧都发一次采样,
|
||||
/// `Captured` 每次都覆盖同一文件 ⇒ **最后写入者说了算**;复审实测 12 次里 7 次写了两次,
|
||||
/// 并把窗口在两次采样之间改成 900×600 之后落盘的就是 900×600 那张)。
|
||||
/// 抓不住:「落盘次数真的是 1」——那要看真实运行的日志 `落盘次数=1`(本测试只测闸门逻辑)。
|
||||
#[test]
|
||||
fn the_capture_gate_opens_once_and_never_again() {
|
||||
// 窗口到第 4 帧才就绪也要恰好请求一次(`window` 未就绪时不置位,保留重试,不会挂死)
|
||||
let mut dispatched = false;
|
||||
let mut requests = 0;
|
||||
for frames in 1..=10u32 {
|
||||
let window_ready = frames >= 4;
|
||||
if should_request_capture(frames, dispatched, true) && window_ready {
|
||||
dispatched = true;
|
||||
requests += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(requests, 1, "整个进程只许请求一次采样");
|
||||
|
||||
assert!(!should_request_capture(1, false, true), "第 3 帧前不许采样");
|
||||
assert!(should_request_capture(3, false, true), "第 3 帧取第一张");
|
||||
assert!(
|
||||
!should_request_capture(99, true, true),
|
||||
"已派发过就不再请求(这条就是 P2-1 的修复本身)"
|
||||
);
|
||||
assert!(
|
||||
!should_request_capture(99, false, false),
|
||||
"交互模式(没有 --screenshot)不采样"
|
||||
);
|
||||
}
|
||||
|
||||
/// **密闭日志三态(M100 r1 P2-2)**:拿不到句柄必须记 `unknown`,不许记 `off`。
|
||||
///
|
||||
/// 抓得住:谁把 `None` 掉成 `off`(那会把「没读到」说成「读到了、没开」——这台机器上从此永远
|
||||
/// 看不出密闭到底生效没有)。
|
||||
/// 抓不住:真实窗口上的位(那要看运行日志里的 `passthrough_ext=on`)。
|
||||
#[test]
|
||||
fn the_passthrough_capability_is_three_states() {
|
||||
assert_eq!(super::caps::passthrough_label(None), "unknown");
|
||||
assert_eq!(
|
||||
super::caps::passthrough_label(Some(0)),
|
||||
"unknown",
|
||||
"句柄 0 = 还没拿到窗口"
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! 「Appearance」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
|
||||
//! 「Appearance」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
|
||||
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
|
||||
//!
|
||||
//! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在
|
||||
//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。
|
||||
//! 本组已经把 `body` 接上了,所以 `tests/mod.rs` 那条「只有一组有真控件」的绊线不用再动。
|
||||
|
||||
use super::super::appearance;
|
||||
use unlucky_design::icons::IconKind;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! 「Downloads」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
|
||||
//! 「Downloads」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
|
||||
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
|
||||
//!
|
||||
//! **归 download 线**:接真控件时改本文件,不必动 `tests/mod.rs`,也不碰别的分组。
|
||||
//! **归 download 线**。接真控件那天要改**三个**文件:本文件 `the_downloads_group_is_still_demo_rows`
|
||||
//! (`body.is_none()` 绊线)+ `settings/download.rs` + `tests/mod.rs` 的跨 section 绊线。
|
||||
//!
|
||||
//! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! 「General」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
|
||||
//! 「General」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的,
|
||||
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
|
||||
//!
|
||||
//! **归 language+appearance 线**:接真控件时改本文件(`GROUP.body` 那条绊线要跟着翻),
|
||||
//! 不必动 `tests/mod.rs`,也不碰别的分组。
|
||||
//! **归 language+appearance 线**。接真控件那天要改**三个**文件:本文件
|
||||
//! `the_general_group_is_still_demo_rows`(`body.is_none()` 绊线)+ `settings/general.rs` +
|
||||
//! `tests/mod.rs` 的跨 section 绊线。
|
||||
|
||||
use super::super::general;
|
||||
use unlucky_design::icons::IconKind;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
//! 「Java」分组自己的断言(M100 从 `settings/tests.rs` 下放)。
|
||||
//! 「Java」分组自己的断言(M100 **新增**:`settings/tests.rs` 里一条 section 断言都没有,
|
||||
//! 那 7 条全是跨 section 的;所以这 3 条是新增,不是搬移)。
|
||||
//!
|
||||
//! **归 java 线**:接真控件时改本文件,不必动 `tests/mod.rs`,也不碰别的分组。
|
||||
//! 判据(M100 交付里正面回答的那条):给 java 加一条新断言 = **只改本文件**。
|
||||
//! **归 java 线**。接真控件那天要改**三个**文件:
|
||||
//! 1. 本文件 `the_java_group_is_still_demo_rows`(它断言 `body.is_none()`,接上就红);
|
||||
//! 2. `settings/java.rs`(写 `body = Some(..)`);
|
||||
//! 3. `tests/mod.rs` 的 `only_the_appearance_group_carries_a_real_control_body`(跨 section 绊线)。
|
||||
//!
|
||||
//! download / general 同型。
|
||||
//!
|
||||
//! 判据(M100 交付里正面回答的那条):给 java 加一条**新断言** = **只改本文件**。
|
||||
|
||||
use super::super::java;
|
||||
use unlucky_design::icons::IconKind;
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
//! 设置页的**跨 section** 守门测试(M100 按 section 下放之后,留在 mod 层的那一份)。
|
||||
//! 设置页的**跨 section** 守门测试(M100:跨 section 的留在这里,section 自己的断言**新增**在
|
||||
//! `tests/<section>_section.rs`)。
|
||||
//!
|
||||
//! ## 为什么这里只剩「跨 section」的断言
|
||||
//!
|
||||
//! 设置页正文接下来由四条线并行接(java / download / language+appearance / ai+about)。每条线要能
|
||||
//! **只改自己的测试**就跑通,所以「某一组自己的行文案 / 图标 / body 现状」一律下放到
|
||||
//! `tests/<section>_section.rs`;本文件只留**必须四组一起看**的断言——下放到任何单组文件里,
|
||||
//! 都会变成「某组替其它三组说话」,四条线并行时必然互相踩。
|
||||
//! **只改自己的测试**就跑通,所以「某一组自己的行文案 / 图标 / body 现状」一律放在
|
||||
//! `tests/<section>_section.rs`(M100 **新增**的 12 条,不是从本文件搬过去的——本文件在 `main`
|
||||
//! 上就只有下面这 7 条跨 section 断言,一条 section 断言都没有);本文件只留**必须四组一起看**的
|
||||
//! 断言——下放到任何单组文件里,都会变成「某组替其它三组说话」,四条线并行时必然互相踩。
|
||||
//!
|
||||
//! 判据(M100 报告里正面回答的那条):**给 java 加一条新断言,改几个文件?** —— 1 个
|
||||
//! (`tests/java_section.rs`)。本文件不动。
|
||||
//!
|
||||
//! 但「java 线**接真控件**那天改几个文件」是另一回事,答案是 **3 个**(M100 r1 复审 p3-3 订正,
|
||||
//! 我最初只写成 2 个):`settings/java.rs`(写 body)+ `tests/java_section.rs` 的
|
||||
//! `the_java_group_is_still_demo_rows`(它断言 `body.is_none()`,接上就红)+ 本文件末尾那条
|
||||
//! `only_the_appearance_group_carries_a_real_control_body`。download / general 同型。
|
||||
//!
|
||||
//! ## ai+about 线要额外改本文件的两处(M100 r1 复审 p3-4)
|
||||
//!
|
||||
//! 它加第 5 组时,除了 `settings/mod.rs` 的 `GROUPS`,**本文件还有两处必改**:
|
||||
//! 1. 顶部 `mod ai_section;`(新测试文件要挂进来,否则永远不编译、永远不跑);
|
||||
//! 2. [`the_split_kept_the_left_column_order`] 断言的是**字面量** `["General","Appearance",
|
||||
//! "Java","Downloads"]`,加第 5 组必红——这是刻意的:左列顺序变了就是观感变了,要人确认。
|
||||
//! 另外 `the_appearance_group_is_where_the_module_says_it_is` 依赖 `APPEARANCE_GROUP == 1`,
|
||||
//! **把新组插在 Appearance 前面会连带红**(所以新组应该排最后)。
|
||||
//!
|
||||
//! ## 站位
|
||||
//!
|
||||
//! 本文件是 `pages::settings::tests`:`super` = `pages::settings`,`super::super` = `pages`。
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
// D36:这是全仓唯一的 unsafe 落点,CI 的 `unsafe-scope` job 会比对文件集合。
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
mod exstyle;
|
||||
|
||||
pub use exstyle::{click_through, is_click_through_style, WS_EX_TRANSPARENT};
|
||||
|
||||
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
|
||||
|
||||
/// Win32 的 `LWA_ALPHA` 标志位:只改整窗 α(`0x2`)。
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! 取证用的**扩展样式探针**(M100 r1 P2-2):这个窗口是不是「点击穿透」。
|
||||
//!
|
||||
//! 为什么单开一个文件:`transparency.rs` 撞 400 行硬线(361 → 447)。`unsafe` 的**放行属性仍只有
|
||||
//! `transparency.rs` 顶部那一个**——lint 级别沿模块树继承,子模块直接继承父模块的
|
||||
//! `#![allow(unsafe_code)]`,所以本文件**不许**再写那行属性(CI 的 `unsafe-scope` 判据是
|
||||
//! 「带放行属性的文件集合恰好等于 `transparency.rs`」)。
|
||||
//!
|
||||
//! 判据来源:winit 0.30 在 Windows 把 `set_cursor_hittest(false)` 落成
|
||||
//! `WS_EX_TRANSPARENT | WS_EX_LAYERED`(`window_state.rs`)。所以读回这一位,就是
|
||||
//! 「取证窗口真的把命中测试关掉了」的 OS 侧证据——比「出图 hash 不变」有判别力得多
|
||||
//! (实测:同 argv 开/关穿透,hash 完全相同)。
|
||||
|
||||
use super::raw_ex_style;
|
||||
|
||||
/// `WS_EX_TRANSPARENT`:**点击穿透**位(`0x0000_0020`)。
|
||||
pub const WS_EX_TRANSPARENT: isize = 0x0000_0020;
|
||||
|
||||
/// 纯判据:扩展样式里 [`WS_EX_TRANSPARENT`] 在不在。
|
||||
///
|
||||
/// 单独拎成 `const fn` 是为了让「位判断写反」有断言盯着——`& WS_EX_TRANSPARENT != 0` 写成
|
||||
/// `== 0` 是这类 getter 最经典的错,而且只会在「开/关恰好反向」时暴露(两边都"有输出")。
|
||||
#[must_use]
|
||||
pub const fn is_click_through_style(ex_style: isize) -> bool {
|
||||
ex_style & WS_EX_TRANSPARENT != 0
|
||||
}
|
||||
|
||||
/// 取证用:窗口**现在**是否点击穿透(`WS_EX_TRANSPARENT` 在扩展样式里)。
|
||||
///
|
||||
/// ## 为什么是三态而不是 `bool`
|
||||
///
|
||||
/// `None` = **没读到**(非 Windows / 句柄无效 / 句柄还没拿到)。这时调用方必须记 `unknown`,
|
||||
/// **不许记成 `off`**——「拿不到」和「没有」是两件事,混为一谈就是假绿(本项目在别处栽过:
|
||||
/// 扫描读不全时报「没有」)。所以这里宁可 `None`,也不用「读失败的 0」冒充「没这一位」。
|
||||
///
|
||||
/// ## 用途
|
||||
///
|
||||
/// 出图时把它打进日志(`pages::harness` 的 `[caps] passthrough_ext=…`),于是「取证窗口到底
|
||||
/// 有没有把指针输入关掉」每次运行都能自证,不依赖外部探针。
|
||||
#[cfg(target_os = "windows")]
|
||||
#[must_use]
|
||||
pub fn click_through(hwnd: isize) -> Option<bool> {
|
||||
if hwnd == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `IsWindow` 只按值接受句柄,不持有指针、无所有权转移;无效句柄返回 0。
|
||||
if unsafe { IsWindow(hwnd) } == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(is_click_through_style(raw_ex_style(hwnd)))
|
||||
}
|
||||
|
||||
/// 非 Windows:**恒 `None`**。空实现不许冒充「没开穿透」(那就是把「不知道」写成「没有」)。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[must_use]
|
||||
pub fn click_through(_hwnd: isize) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
/// `IsWindow`:句柄是否指向一个存在的窗口(读扩展样式前的门槛)。
|
||||
///
|
||||
/// 为什么要先问一句:`GetWindowLongPtrW` 对无效句柄返回 0,与「扩展样式全 0」不可区分——
|
||||
/// 不先验一次,[`click_through`] 就会把「读不到」说成「没开穿透」。
|
||||
fn IsWindow(hwnd: isize) -> i32;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{click_through, is_click_through_style, WS_EX_TRANSPARENT};
|
||||
use crate::platform::win32::transparency::WS_EX_LAYERED;
|
||||
|
||||
/// **抓得住**:`& WS_EX_TRANSPARENT != 0` 被写成 `== 0`(位判断反向)——这是这类 getter 最经典
|
||||
/// 的错,而且只会在「开/关恰好互换」时暴露:日志里永远有值,只是意思反了。
|
||||
/// **抓不住**:真实窗口上 winit 到底置没置这一位(那要出图日志里看 `passthrough_ext=on`)。
|
||||
#[test]
|
||||
fn the_click_through_bit_cannot_be_read_backwards() {
|
||||
assert_eq!(WS_EX_TRANSPARENT, 0x0000_0020, "位值照 Win32 头文件");
|
||||
assert!(is_click_through_style(WS_EX_TRANSPARENT));
|
||||
assert!(
|
||||
is_click_through_style(WS_EX_TRANSPARENT | WS_EX_LAYERED),
|
||||
"winit 的鼠标穿透是两位一起置的,这种组合必须是 true"
|
||||
);
|
||||
assert!(
|
||||
!is_click_through_style(WS_EX_LAYERED),
|
||||
"只有分层位不算穿透——LWA_ALPHA 也加这一位,反向了会把正常窗口误判成穿透"
|
||||
);
|
||||
assert!(!is_click_through_style(0));
|
||||
}
|
||||
|
||||
/// **抓得住**:「拿不到」被记成「没有」。句柄 0 / 无效句柄 / 非 Windows 必须是 `None`
|
||||
/// (调用方据此写 `unknown`),不许用「读失败的 0」冒充「这一位没开」。
|
||||
/// **抓不住**:一个**有效**窗口上的读数(那要真窗口,见 harness 的出图日志)。
|
||||
#[test]
|
||||
fn click_through_reports_unknown_instead_of_off_when_it_cannot_read() {
|
||||
assert_eq!(click_through(0), None, "句柄 0 = 还没拿到窗口 ⇒ unknown");
|
||||
assert_eq!(click_through(1), None, "无效句柄 ⇒ unknown(不许掉成 off)");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(
|
||||
click_through(0x1234),
|
||||
None,
|
||||
"非 Windows 的空实现必须恒 None,不许冒充 off"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,11 @@ fn flavor_slug_is_stable_for_filenames() {
|
||||
|
||||
/// **交互模式不许被取证开关污染**(M100 的头号不变量)。
|
||||
///
|
||||
/// 抓得住:谁把 `--screenshot` 的默认值 `Some(Rest)` 顺手设成了 `Options::force_state` 的默认值
|
||||
/// (或者把默认往下挪一层,落到 `overridden` 的 `None` 分支上)——那会让**用户窗口里所有按钮
|
||||
/// 永远不显示 hover**,是产品事故,而不是取证问题。
|
||||
/// 抓不住:取证模式有没有真的压掉 hover(那是下一条)。
|
||||
/// 抓得住:`overridden` 的 `None` 分支被改坏——例如把「跟控件自己的状态走」写成了固定
|
||||
/// `Active`/`Hovered`(那会让**用户窗口里所有按钮**不再反映 hover/pressed,是产品事故)。
|
||||
/// 抓不住:**默认值被顺手设成 `Some(Rest)`** 这类错误——`overridden(None, …)` 根本不会被调用,
|
||||
/// 本条照样绿;那一条由 `cli::tests::screenshots_default_to_the_rest_state` 抓(它断言
|
||||
/// 交互模式 `force_state == None`)。两条各守一半,别拿一条当另一条的证据。
|
||||
#[test]
|
||||
fn interactive_mode_still_hovers() {
|
||||
use iced::widget::button::Status;
|
||||
@@ -77,7 +78,11 @@ fn interactive_mode_still_hovers() {
|
||||
/// 抓得住:谁把 `Rest` 接成「保留现状」(那等于没钉:系统指针 hover 照样漏进图——M99 复审实测
|
||||
/// 连跑 8 次翻盘 1 次就是这里没钉住)。
|
||||
/// 抓不住:① 不经过 `overridden` 的控件(标题栏以外的裸 `iced::button`,例如设置页「外观」区);
|
||||
/// ② 窗口到底还收不收鼠标事件(那是 harness 的 passthrough,靠出图日志的 `指针事件=N` 自证)。
|
||||
/// ② 窗口到底还收不收鼠标事件(那是 harness 的 passthrough,看 `[caps] passthrough_ext=`);
|
||||
/// ③ **`Rest` 与「真 Disabled」撞语义**:`components::buttons::assemble()` 把「无 `on_press`」的
|
||||
/// 按钮默认画成 Disabled,而 `Rest => Enabled` 会把它画成可用。当前零影响(8 处 `forced_state(`
|
||||
/// 调用点与 shell 的 caption / nav 全带消息),但将来若有人加一个**无消息**的按钮又被 `Rest` 罩住,
|
||||
/// 那张取证图会画出「一个本应禁用的按钮」而这条断言照绿。
|
||||
#[test]
|
||||
fn rest_suppresses_cursor_hover() {
|
||||
use iced::widget::button::Status;
|
||||
|
||||
Reference in new issue
Block a user