Files
Unlucky-Launcher/crates/app-iced/src/shell/mod.rs
T
AskaEth 5c40c64b5f fix(app-iced): M93 r3 —— 复审 r1 四条返工(Canvas 不吃 α / 平铺补缝 / 诊断上移 / 文档)
先 `git rebase main`(M92 `eae7ae0` 已在主线;`Cargo.lock` 与 `crates/app-iced/Cargo.toml`
自动合并,`image-without-codecs` 与 M92 的 `unlucky-i18n` 两段都在)。

P2-2(口径违约)Canvas 层不许吃面板 α
- `Ui::layer()` 改成按令牌 `MaterialLayer::allows_translucency()` 判定:Canvas 恒不透明,
  只有 Card / Overlay 乘 `panel_alpha`。判据取令牌的不变量,三个调用点(标题栏 / 导航轨 /
  页头带)没有各自特判。
- 守门:`the_canvas_layer_stays_opaque_while_panels_take_the_alpha`(α=0.55 时 Canvas.a==1.0、
  Card/Overlay.a==0.55;CPU 档连卡片都是 1.0)。

P2-1 平铺不许留缝
- `background::tiles()` 超 `MAX_TILES` 时改成**放大图块步长**(`Tiles { cols, rows, scale }`,
  按 1.15 逐级放大到块数进预算),而不是减列/减行;`MAX_TILE_SCALE` 提到 1024 只是安全阀
  (1×1 的图在 1180×760 上需要 ~118 倍才能用 64 块盖满)。
- `picture::tiled()` 用 `ContentFit::Fill` + 格子尺寸 = `源尺寸 × scale`(同比例,不变形):
  `None` 会按图片原生尺寸画、比格子小就留空,正是那道缝。
- 守门:`tile_grid_always_covers_the_viewport_within_budget`(6 组尺寸,含 120×120 与 1×1,
  断言 `covered >= viewport` 且 `count <= MAX_TILES`);背景层底下本来就有不透明画布底色兜底。

P2-3 诊断上移到首屏
- 诊断块(读盘失败红字 / CPU 降级 / 配色回落 / 当前配色层级)从卡片末尾移到「背景图路径」
  紧下方:1180×760 首屏可见(截图 `r4-broken-path.png` 里红字在 y≈594)。

P2-4 文档对齐
- `transparency.rs`:`WS_EX_LAYERED` 的归属改成「本文件的 `ensure_layered` 负责」,
  窗口创建侧只拿句柄;`platform/mod.rs` / `lib.rs` 的模块表同步。
- 删掉「马赛克取样」这类已删实现的现在时描述(`background` / `picture` / `app` 的注释),
  保留一句 r1→r2 的历史说明;`app/tests/appearance.rs` 的测试意图跟着改。

门禁(工作区级,最终树):`cargo fmt --all --check` 干净;
`cargo clippy --workspace --all-targets -- -D warnings` 无输出;
`cargo test --workspace` 全绿(app-iced 299 lib + 6 集成守门、presenter 126、其余 crate 全过)。
出图:`r4-canvas-opaque-panel55{,-dark}.png`(Canvas 不透明 + 面板透底)、
`r4-tile-120.png`(120×120 平铺右缘无裸白)、`r4-broken-path.png`(诊断首屏可见)。
2026-11-02 18:18:37 +08:00

400 lines
15 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 {
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)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::component_map::Component;
use unlucky_design::typography::Platform;
use crate::theme::Families;
fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(
flavor,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn a_screen_only_uses_four_text_sizes_at_most() {
let ui = ui(SchemeFlavor::Dark);
let card = Component::OutlinedCard.spec();
let sizes = [
ui.emphasized(TypeRole::TitleLarge).size_px,
ui.emphasized(TypeRole::LabelLarge).size_px,
ui.emphasized(card.text).size_px,
ui.style(card.support.unwrap_or(card.text)).size_px,
ui.style(TypeRole::LabelMedium).size_px,
];
let mut unique = sizes.to_vec();
unique.sort_by(f32::total_cmp);
unique.dedup();
assert!(
unique.len() <= 4,
"rubric D4:一屏字号档 ≤4(实际 {} 档 {unique:?})",
unique.len()
);
assert!(unique[0] >= 12.0, "最小档不下 12px(caption ≥11)");
assert!(unique[unique.len() - 1] <= 28.0, "列表页标题不得 >28px");
}
#[test]
fn flavor_slug_is_stable_for_filenames() {
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Light)), "light");
assert_eq!(crate::shot::flavor_slug(&ui(SchemeFlavor::Dark)), "dark");
}
/// M86:标题栏 caption 图标缩到 20 后,**容器一个数都没动**(46×48),图标也还在框里。
#[test]
fn caption_icon_shrinks_without_touching_the_container() {
let ui = ui(SchemeFlavor::Light);
let metrics = ui.metrics();
let caption = icons::CAPTION_PX;
assert_eq!(metrics.titlebar_button_width, 46.0, "容器宽不许跟着图标改");
assert_eq!(metrics.titlebar_height, 48.0, "容器高不许跟着图标改");
assert!(
caption < metrics.titlebar_button_width,
"图标 {caption} 必须小于按钮宽 {}",
metrics.titlebar_button_width
);
assert!(
caption < metrics.titlebar_height,
"图标 {caption} 必须小于按钮高 {}",
metrics.titlebar_height
);
// 官方口径 24 仍在令牌里(`IconSize::Toolbar`),偏离只发生在 caption 这一个入口。
assert!(
caption < unlucky_design::icons::IconSize::Toolbar.px(),
"caption 图标必须小于官方 24(缩小是用户点名的)"
);
}
}