Files
Unlucky-Launcher/crates/app-iced/src/pages/harness/app.rs
T
AskaEth 7b80b0e314 feat(app-iced): --force-state 穿过设置页与外观区(M285 FORCEDWIRE)
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 原样。
2026-11-12 23:12:20 +08:00

268 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 截图 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(())
}