先 `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`(诊断首屏可见)。
400 lines
15 KiB
Rust
400 lines
15 KiB
Rust
//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。
|
||
//!
|
||
//! ## 与参考判据的对应(`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(缩小是用户点名的)"
|
||
);
|
||
}
|
||
}
|