Files
Unlucky-Launcher/crates/app-iced/src/shell/mod.rs
T
AskaEth 2061296615 feat(app-iced,pages): M100 取证密闭 + harness 参数拆分 + 设置页测试按 section 下放
四件前置事,都是为了设置页正文四条 section 线能并行(不写任何 section 正文):

1. 取证密闭(两条通道各有各的机制,证据分开算)
   - 产品 bin:`cli::ForcedState` 加 `Rest`(静息态),给了 `--screenshot` 且没显式
     `--force-state` 时默认 `Some(Rest)`;交互模式仍是 `None`(`shell::overridden`
     与 `app/view.rs` 的 `model()` 各补一支)。
   - harness:截图窗口开 `window::enable_mouse_passthrough`(winit 的
     `set_cursor_hittest(false)`)⇒ 窗口收不到指针事件 ⇒ 全树控件恒为 Active。
     为什么用通道级而不是逐控件灌 forced:设置页「外观」区的滑块/文本框/裸 button 都不消费
     forced,逐控件做要改十几个组件文件,而新产品组件随时会漏。
   - harness 同时数指针事件并打进日志(`指针事件=N`):让「这张图见过几个鼠标事件」变成
     可读数字,替代「连跑几次 hash 一样」那种恒真的假绿。

2. harness 的外观真状态入口:`--appearance=real` 让设置页走 `appearance: Some(..)`
   (工厂态 + `ULL_APPEARANCE_*`),新入口 `pages::app_with_appearance`。
   `--screen=settings` 之前就有了,但 `appearance` 恒为 None,D30 要的那一屏根本不存在。

3. `pages/harness.rs` 400 行 → 拆 `pages/harness/request.rs`(`#[path]` 范式),
   纯搬移部分 240 行 + 新增密闭代码。拆分等价性:同一 argv 的 PNG sha256 与拆分前一致。

4. `pages/settings/tests.rs` 按 section 下放成 `tests/{general,appearance,java,download}_section.rs`,
   mod 层只留跨 section 断言(每条写清守什么、为什么不能下放)。
   给某组加一条新断言 = 只改它自己那个文件。

顺带:`shell/mod.rs` 在 HEAD 就 399 行、`cli.rs` 394 行,两处都按同一范式把 tests 搬成
`shell/tests.rs` / `cli/tests.rs`(断言一字不动,逐字节可 diff)。

变异验证:把四处实现各改坏一处(默认 Rest、passthrough 默认值、overridden 的 Rest 分支、
view 的 Rest 映射),对应 4 条断言全红(390 passed / 4 failed),恢复后 394 passed。
2026-11-03 14:22:40 +08:00

331 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.
//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。
//!
//! ## 与参考判据的对应(`research/12b-component-fidelity.md`)
//!
//! | 部件 | 参考 | 令牌落点 |
//! |---|---|---|
//! | 标题栏高度 | 16.2 桌面标题栏 **40**(登记偏离) | `LM.titlebar_height = 48`(M63 §1.4/R13 桌面档,令牌优先) |
//! | 标题栏按钮 | 16.5 官方图标 **24** → 本项目 **20**(M86 项目偏离,用户点名「图标稍大」) | `icons::CAPTION_PX`(令牌 `TITLEBAR_CAPTION_ICON_PX`)+ `LM.titlebar_button_width = 46` |
//! | 页头 | 16.1 官方 AppBar **64**;本项目 **56** | `LM.page_header_height = 56`(已有偏离登记) |
//! | 卡片 | 3.x Outlined:**Medium(12)**、L0、`OutlineVariant` 1px、`SurfaceContainerLow` | `Component::OutlinedCard.spec()` + `STROKE_PX` |
//! | 字级 | 16.4 标题 `TitleLarge`;卡片 `TitleMedium` / `BodySmall` | `Ui::style` / `Ui::emphasized` |
//!
//! 导航轨在 [`nav`] 子模块里(12b §5 的判据表也在那边)。
//!
//! 一屏只出现 4 档字号(22 / 16 / 14 / 12),且**没有实心 accent**(rubric K2:一屏最多一个实心
//! Primary,骨架期一个都不要)。阴影只给浮层,骨架里没有浮层,所以**全屏零阴影**(rubric K3)。
mod fallback;
mod nav;
pub use nav::NavItem;
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, mouse_area, row, space, stack, text};
use iced::{mouse, Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use iced::{window::Direction, Theme as IcedTheme};
use unlucky_design::color::{Role, StateLayer};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::app::Message;
use crate::cli::ForcedState;
use crate::icons;
use crate::theme::Ui;
/// 整棵树(**内容槽位版**:外壳画标题栏 / 缩放热区 / 导航轨,中间那块交给调用方)。
///
/// 这是产品页面走的入口(M90 r2 / P1-1)。为什么要它:`decorations: false` 的无边框窗口里,
/// 拖动 / 缩放 / 最小化 / 关闭这四条消息的构造点**只有本模块**,页面树自己画的「标题栏留白带」
/// 没有任何控件,于是产品页会变成「拖不动、缩不了、点不到关闭」。
///
/// 分工写死:
///
/// - 本模块负责:标题栏(拖动面 = **整条 48px 栏里从窗口左缘到第一个 caption** 的 `mouse_area`,
/// 没有单独的 8px 热区;三个 caption 键各 46×48)、东/南两条缩放热区(各 `Space::X1` = 4px)、
/// 导航轨、根容器底色;
/// - 调用方负责:`content`(页面内容区)。它拿到的可用宽度是 `窗口宽 − rail 88`,
/// 而页面自己的 `layout::frame_metrics` 仍以**窗口宽**为输入(它内部会再减 rail 与页边距),
/// 两边的账不会算串。
///
/// 面板 α(D36 / M93)不从这里传:它是**表面色**的属性,唯一施加点是 `Ui::layer`
/// (`crate::theme`)。外壳的根容器 / 标题栏 / 导航轨都走 `ui.layer(..)`,因此自动同源。
pub fn view_with_content<'a>(
ui: &'a Ui,
selected: NavItem,
app_title: &'a str,
content: Element<'a, Message>,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
view_with_background(ui, selected, app_title, None, content, forced)
}
/// 同 [`view_with_content`],但可以在整棵树**最底下**垫一层背景(M93 的真图背景层)。
///
/// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!`
/// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。
pub fn view_with_background<'a>(
ui: &'a Ui,
selected: NavItem,
app_title: &'a str,
background: Option<Element<'a, Message>>,
content: Element<'a, Message>,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let work_area = column![
titlebar(ui, app_title, forced),
row![nav::rail(ui, selected, forced), content]
.width(Length::Fill)
.height(Length::Fill),
]
.width(Length::Fill)
.height(Length::Fill);
window_frame(ui, background, work_area.into())
}
/// 整棵树(M68 骨架版:内容区是「页头 + 一张占位卡」)。
///
/// **保留给 `Route::Agent` 回落**(AI 独立页 P1 才落地)。删掉它会连带
/// `content` / `label` / `nav::rail` 变成死代码,而 `mod shell` 是私有的 ⇒ `-D warnings` 直接红。
pub fn view<'a>(
ui: &'a Ui,
selected: NavItem,
app_title: &'a str,
page_title: &'a str,
body: &'a str,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
view_with_content(
ui,
selected,
app_title,
fallback::content(ui, page_title, body),
forced,
)
}
/// 无边框窗口的「外壳壳子」:背景层 + 工作区 + 两条缩放热区 + 根容器底色。
///
fn window_frame<'a>(
ui: &'a Ui,
background: Option<Element<'a, Message>>,
work_area: Element<'a, Message>,
) -> Element<'a, Message> {
// 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。
// **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px,
// 视觉复审 P3-1)。叠层容器自己铺满,再用 align 把 4px 热区贴到边上,布局宽保持 1180。
// 东侧热区从标题栏下方开始,避免吃掉三个 caption 按钮最右 4px 的点击区。
let thickness = ui.space(Space::X1);
let east = container(
column![
space::vertical().height(ui.metrics().titlebar_height),
resize_edge(
Direction::East,
(Length::Fixed(thickness), Length::Fill),
mouse::Interaction::ResizingHorizontally,
),
]
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::End);
let south = container(resize_edge(
Direction::South,
(Length::Fill, Length::Fixed(thickness)),
mouse::Interaction::ResizingVertically,
))
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::End);
// 背景在最底层(M93 / D34)。有背景层时**根容器不再盖实色画布**:否则那张不透明的
// `Canvas` 会把背景图整块挡住(r2 实测:panel α=1 时背景完全看不见)。
// 各面板(标题栏 / 导航轨 / 卡片 / 页头带)自己画底色,空隙里露出的就是背景图。
let has_background = background.is_some();
let shell = match background {
Some(layer) => stack![layer, work_area, east, south],
None => stack![work_area, east, south],
};
container(shell)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| {
if has_background {
container::Style::default()
} else {
filled(ui.layer(MaterialLayer::Canvas))
}
})
.into()
}
/// 窗口缩放热区(透明;玻璃/底色由根容器提供)。
///
/// 拖动时调 `window::drag_resize(id, direction)`——Windows/X11 走 winit 原生实现,
/// **macOS 返回 `NotSupported`**(M60 Q4),如实登记在交接文档里。
fn resize_edge<'a>(
direction: Direction,
size: (Length, Length),
interaction: mouse::Interaction,
) -> Element<'a, Message> {
let (width, height) = size;
mouse_area(container(space::horizontal()).width(width).height(height))
.interaction(interaction)
.on_press(Message::ResizeStart(direction))
.into()
}
// ---------------------------------------------------------------- 标题栏
fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option<ForcedState>) -> Element<'a, Message> {
let metrics = ui.metrics();
// 拖动热区:从窗口左缘到第一个按钮(按钮自己不能被拖区吞掉,所以只包左侧一段)
let drag_area = mouse_area(
container(label(
ui,
TypeRole::LabelLarge,
true,
title,
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([0.0, ui.space(Space::X4)])),
)
.on_press(Message::DragWindow);
let bar = row![
drag_area,
caption(ui, IconKind::Minimize, Message::Minimize, forced),
caption(ui, IconKind::Maximize, Message::ToggleMaximize, forced),
caption(ui, IconKind::Close, Message::Close, forced),
]
.width(Length::Fill)
.height(metrics.titlebar_height)
.align_y(Alignment::Center);
container(bar)
.width(Length::Fill)
.height(metrics.titlebar_height)
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
/// 标题栏按钮(自绘最小化/最大化/关闭)。
///
/// 图标必须**再包一层固定尺寸的容器**:iced 的 `button` 在 layout 里给内容 Fill 约束,
/// 直接把 `svg(...).width(24)` 塞进 button,usvg 会按 Contain 撑满内区(实测渲染盒 46 → 视觉线宽
/// 从 1.5 变 2.9,视觉复审 P2-2)。Container 尊重子元素的 Length,所以这层是「锁尺寸」用的。
///
/// **图标尺寸 = [`icons::CAPTION_PX`](20,项目偏离;官方 24),容器 46×48 不动**(M86):
/// 用户原话是「保持其容器尺寸不变的前提下把图标改小些」,所以缩放只落在图标盒与 SVG 位图上,
/// `metrics.titlebar_button_width` / `titlebar_height` 一个都不许跟着改。
fn caption<'a>(
ui: &'a Ui,
kind: IconKind,
message: Message,
forced: Option<ForcedState>,
) -> Element<'a, Message> {
let metrics = ui.metrics();
// 用 `center(Length::Fill)` 而不是只设 `width(20).height(20)`:后者会让容器缩到 20×20 并贴到
// button 内容的左上角(button 不居中「收缩后」的内容,r1 实测图标跑到 cell 左上 (left+11.5, 11.5));
// `center(Fill)` = 撑满按钮内容区 + **两轴**居中,字形仍是 20 盒、命中区仍是 46×48(Q21:
// 只设 `align_y` 不设 `align_x` 会让图标贴左)。
let icon = container(icons::view_at::<Message>(
kind,
ui.color(Role::OnSurfaceVariant),
icons::CAPTION_PX,
))
.width(icons::CAPTION_PX)
.height(icons::CAPTION_PX)
.center(Length::Fill);
button(icon)
.width(metrics.titlebar_button_width)
.height(metrics.titlebar_height)
.padding(0)
.style(move |_theme: &IcedTheme, status| {
let background = match overridden(forced, status) {
button::Status::Hovered => Some(Background::Color(ui.state_over(
StateLayer::Hover,
Role::OnSurface,
Role::Surface,
))),
button::Status::Pressed => Some(Background::Color(ui.state_over(
StateLayer::Pressed,
Role::OnSurface,
Role::Surface,
))),
button::Status::Active | button::Status::Disabled => None,
};
button::Style {
background,
text_color: ui.color(Role::OnSurfaceVariant),
border: Border::default(),
shadow: Shadow::default(),
snap: true,
}
})
.on_press(message)
.into()
}
// ---------------------------------------------------------------- 小工具
/// 一个文字元素:字号/行高/字重全部来自令牌排版档。
pub(super) fn label<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
color: Color,
) -> Element<'a, Message> {
let style = if emphasized {
ui.emphasized(role)
} else {
ui.style(role)
};
text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.into()
}
/// 取证用的状态覆盖(`--force-state`):只改画出来的状态,不动任何状态机。
pub(super) fn overridden(forced: Option<ForcedState>, status: button::Status) -> button::Status {
match forced {
// `Rest` = 取证图的常态:**明确**画成 Active,把系统光标带来的 hover/pressed 压掉。
// 截图通道默认就是它(见 `cli::Options::parse` 的尾部),否则同一份代码会因指针位置出不同的图。
Some(ForcedState::Rest) => button::Status::Active,
Some(ForcedState::Hover) => button::Status::Hovered,
Some(ForcedState::Pressed) => button::Status::Pressed,
None => status,
}
}
pub(super) fn filled(color: Color) -> container::Style {
container::Style {
background: Some(Background::Color(color)),
..container::Style::default()
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;