pages::Model.forced 此前只到得了外壳 + 实例页(唯一消费者 pages/instances.rs:159), 设置页「外观」区的滑块/文本框/裸 iced::button 全都不消费它 ⇒ M284 那四处状态层 (语言档 / 主题档 / 日志 action_button / 外观 choice)只有单测证据,做不出像素判据。 改动: - settings/mod.rs: resolve_state(forced, status) 与 forced_button(button, forced) 成为全仓唯一两个「forced 怎么解释」的点;view → form → section_columns 一路 新增 forced 形参(默认 None ⇒ 今天的行为逐字节不变)。 - Group.body(registry.rs)加第 4 个形参 Option<Interaction>,六组统一签名。 - 四类裸 iced::button 控件把 forced 递进样式闭包并经 resolve_state 落地: 语言档 chip(general/language.rs)、主题档 chip(general/theme.rs)、 日志 action_button(logs_row.rs)、外观 choice(appearance/section/mod.rs, 含 accent/window/bg_image/colour_source 共 10 处调用)。 - components::buttons::Button 的三组(update / ai / java)走 forced_button 的 .state()。 - harness: request.rs 解析 --force-state=rest|hover|pressed(未知值报错,词与产品 cli.rs 同一套),slug 加 -hover/-pressed;app.rs 把它写进 model.forced, 截图通道默认 Rest 的纪律保持(显式值优先)。 - pages/mod.rs: 唯一调用点把 model.forced 传给 settings::view(397 → 399 行)。 单测:resolve_state 的 Some 覆盖 / None 退回;forced_button 的 Some 钉态 / None 原样。
268 lines
13 KiB
Rust
268 lines
13 KiB
Rust
//! 截图 harness 的**运行部分**(M100 r1:`harness.rs` 撞 400 行后从那里搬出来)。
|
||
//!
|
||
//! 这里只有 [`run`]:探后端 → 造外观真状态 → 起 iced → 出图 → 按验收口径打日志。
|
||
//! 参数解析在 [`super::request`],窗口状态机与采样闸门在 [`super`],OS 侧密闭指标在
|
||
//! [`super::caps`]。搬出来没有改行为(同一 argv 的 PNG sha256 不变)。
|
||
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
use std::sync::Arc;
|
||
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 super::{demo_model, Harness, Message, Request, CAPTURE_POSITION};
|
||
|
||
/// **终态判据**:取证运行跑完了却一张图都没落盘 ⇒ 失败。
|
||
///
|
||
/// 单独拎成纯函数是为了让它能被断言(`run()` 要真窗口才能跑)。它守的是 M100 r3 P2-1 那个
|
||
/// 真实缺陷:窗口在第 3 帧消失时,`window::frames()` 不再产消息 ⇒ 帧超时兜底**求值不到**,
|
||
/// 而 iced_winit 对「最后一个窗口 Destroyed」直接退出 ⇒ 曾经是 **exit 0 + 没有 PNG**。
|
||
///
|
||
/// ## 为什么必须有 `capture_mode` 前提(M100 r4 P2-1)
|
||
///
|
||
/// 不给 `--screenshot` 是文档明确支持的**交互预览模式**(「没给 `--screenshot` 就只是开窗看」):
|
||
/// 该模式 `subscription()` 直接 `Subscription::none()`,**永远不会有 `Captured`** ⇒ `written` 恒 0。
|
||
/// 没有这个前提,用户**正常关掉预览窗**会拿到 exit 1 和一句「取证结束但没有任何图落盘」的
|
||
/// 假消息——那是把"从未要求出图"判成"取证失败",通道的退出码就此失去区分力
|
||
/// (真崩溃 / 真取证失败 / 正常关个预览窗全是 1)。
|
||
///
|
||
/// ## 为什么用计数器而不是 `path.exists()`
|
||
///
|
||
/// `written` 是**本进程**成功落盘的次数,不会被执行前就存在的旧 PNG 骗过;换成「目标文件存在」
|
||
/// 会在脏路径下给出「exit 0 + 上一次的旧图」。这一点写在这里,免得后来人「顺手改成 exists」。
|
||
pub(super) fn no_evidence(capture_mode: bool, written: u32) -> bool {
|
||
capture_mode && written == 0
|
||
}
|
||
|
||
/// 启动(阻塞到出图或关窗)。
|
||
///
|
||
/// **取证通道的硬规则(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` 来,打日志的是它算出来的生效值。
|
||
//
|
||
// M250:**gallery 也要这一份** —— 强调色出图矩阵拍的是组件库(buttons + progress),
|
||
// 而 `--appearance=real` 按设计只对设置页生效(`request.rs` 的守卫,别绕过它)。所以判据加
|
||
// 一条:设了 `ULL_APPEARANCE_ACCENT` 就造状态,让 `Ui` 按强调色求值(设置页的语义不变)。
|
||
let wants_accent = std::env::var_os(appearance::accent::ENV_ACCENT).is_some();
|
||
let appearance = (request.real_appearance || wants_accent).then(|| {
|
||
let mut state = appearance::State::new(
|
||
&AppearancePrefs::factory(),
|
||
&AppearanceBase::factory(),
|
||
request.backend,
|
||
request.flavor,
|
||
);
|
||
state.apply_env();
|
||
// M159:`ULL_SETTINGS_GROUP=<下标>` 在 harness 里也生效(产品路径走
|
||
// `app/state/construct.rs` 的同一条判据)。没有它就只能拍默认的「General」组,
|
||
// 而设置页有五组——「更新」组的验收图正是靠它拍出来的。
|
||
if let Some(index) =
|
||
crate::ui::lang::screenshot_settings_group(request.screenshot.is_some())
|
||
{
|
||
state.selected_group = index;
|
||
}
|
||
eprintln!(
|
||
"[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \
|
||
配色来源={:?} accent={} 选中分组={}",
|
||
state.window_opacity,
|
||
state.panel_alpha,
|
||
state.effective_panel_alpha(),
|
||
state.bg_enabled,
|
||
state.bg_fill,
|
||
state.bg_dim,
|
||
state.palette_source,
|
||
state.accent.log_token(),
|
||
state.selected_group,
|
||
);
|
||
state
|
||
});
|
||
|
||
// 落盘计数器(终态不变量 + 日志共用):只在 `shot::save` 成功后自增。
|
||
let written = Arc::new(AtomicU32::new(0));
|
||
|
||
let application = |backend: renderer::Kind| {
|
||
let boot = request.clone();
|
||
let appearance = appearance.clone();
|
||
// 落盘次数要能被 `run()` 读到(终态不变量用它),所以走共享计数器而不是 `Harness` 的私有字段:
|
||
// 值只在 `shot::save` **成功**之后自增,是「真的写出去了」而不是「我们打算写」(U1)。
|
||
let written = Arc::clone(&written);
|
||
iced::application(
|
||
move || {
|
||
let mut model = demo_model(boot.screen, boot.width, boot.height);
|
||
// 截图通道把消费 forced 的控件钉成常态,与产品 `--screenshot` 的 `ForcedState::Rest`
|
||
// 同一条纪律;显式的 `--force-state=hover|pressed` **优先**(那是故意要画那个态)。
|
||
//
|
||
// M285:这条线现在真的到得了设置页 / 外观区——`pages::Model.forced` 经
|
||
// `settings::view(.., forced)` → `Group.body` → 语言档 / 主题档 / 日志按钮 / 外观 choice,
|
||
// 由 `settings::resolve_state` 落到样式闭包。此前那四处的状态层只有单测证据。
|
||
model.forced = boot
|
||
.force_state
|
||
.or_else(|| boot.screenshot.is_some().then_some(Interaction::Enabled));
|
||
// M250:gallery 出图也要能看见强调色。**规则不在这里抄** —— 调产品用的同一个
|
||
// `theme::tokens`(优先级只定义一次)。如实补一句:harness 不建 `App`,所以这里
|
||
// 没有 D38 取色方案(第三个实参恒 `None`),出图**不覆盖**那条分支。
|
||
let (ui, _) = crate::theme::tokens(
|
||
boot.flavor,
|
||
appearance.as_ref().and_then(|state| state.accent.color()),
|
||
None,
|
||
families,
|
||
);
|
||
(
|
||
Harness {
|
||
ui,
|
||
model,
|
||
appearance: appearance.clone(),
|
||
form: crate::pages::demo_launch_form(),
|
||
request: boot.clone(),
|
||
window: None,
|
||
hwnd: None,
|
||
placed: None,
|
||
frames: 0,
|
||
dispatched: false,
|
||
written: Arc::clone(&written),
|
||
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?;
|
||
|
||
// **终态不变量(M100 r3 P2-1)**:取证跑完了却一张图都没落盘 = 失败。
|
||
//
|
||
// 为什么要有这一条(复审变异实测):兜底原来只有「帧数超时」,而 `window::frames()` 只在
|
||
// `Event::Window(RedrawRequested)` 时产消息 —— 窗口一旦消失就不再 redraw ⇒ `Message::Frame`
|
||
// 不再产生 ⇒ `capture_stalled` **永远求值不到**;同时 iced_winit 对「最后一个窗口 Destroyed」
|
||
// 直接 `Control::Exit`,`run()` 返回 `Ok(())`。表现是 **exit 0 + 没有 PNG**,与成功跑同一形状
|
||
// 只少了出图那几行 —— 只看退出码的脚本会读到「成功 + 零证据」。
|
||
//
|
||
// 所以判据从「超时」升级成「终态」:**跑完了没有图就是失败**。它比超时强,因为覆盖
|
||
// 「帧不再产生」这一类;帧超时那条保留,管「有帧但采样回不来」。
|
||
//
|
||
// **前提是这次真的是取证运行**(M100 r4 P2-1):不给 `--screenshot` 的交互预览模式本来
|
||
// 就不出图,判成失败会打出假消息、并让退出码失去区分力。前提写在判据里,不写在这里。
|
||
if no_evidence(
|
||
request.screenshot.is_some(),
|
||
written.load(Ordering::Relaxed),
|
||
) {
|
||
eprintln!("[harness] 取证结束但没有任何图落盘(窗口提前消失 / 落盘失败),按失败退出");
|
||
std::process::exit(1);
|
||
}
|
||
|
||
eprintln!(
|
||
"[harness] 用时 {:.0} ms",
|
||
started.elapsed().as_secs_f64() * 1000.0
|
||
);
|
||
Ok(())
|
||
}
|