Files
Unlucky-Launcher/crates/app-iced/src/shell/mod.rs
T
AskaEth df4ff373b7 feat(app-iced): M68 —— iced 0.14 骨架(后端探测/字体基座/自绘标题栏/导航轨/深浅主题/截图通道)
新增 crates/app-iced(package `unlucky-app-iced`,bin `unlucky-launcher-iced`),与 Slint 版并存,
为 D26「UI 从头写」打地基。本 mission 只做骨架:能开窗、颜色/字体对、层级在,不做业务页面。

- 后端(D27):启动探测 wgpu 适配器(只看 device_type,避开 WARP 误判)→ 写 ICED_BACKEND;
  auto/gpu/cpu 三条路径都保留 tiny-skia 兜底,绝不硬失败;`--renderer=cpu` 跳过探测(省 ~0.85s)
- 字体(D28 前半):令牌三平台字体栈 → 用 iced 全局 FontSystem 查装机情况 → 族名与 400/500/700
  字重落点(缺档按 Nearest/Synthetic 记录)打一行启动日志
- 令牌适配层 theme::Ui:唯一把 unlucky-design 的值翻成 iced Color/Font/Border/Radius/Theme 的地方;
  49 个 M3 角色留在自己的 Scheme 里,iced 的 6 色 Palette 只给 devtools
- 骨架:1180×760 无边框窗口、48 高自绘标题栏(drag + 东/南 drag_resize + 最小化/最大化/关闭)、
  88 宽导航轨(56×32 SecondaryContainer 药丸,由令牌推导)、页头 56 + 一张 OutlinedCard
- 主题:系统主题探测 + --theme,切主题只重建 Ui(Theme::name 变化触发重算),不动 UI 状态
- 截图通道:`--screenshot <png>` + `--screenshot-theme`(真开窗第 3 帧出图),以及
  `--diff <a> <b>` 自带逐像素对比(CI 不依赖 Python/ImageMagick)
- 图标:令牌 SVG path → iced svg 控件(颜色走 alpha 蒙版,深浅共用一份位图缓存)
- 门禁:tests/no_hardcoded_tokens.rs 扫源码挡色值/尺寸字面量 + 挡 slint 依赖(自检也算过)

实测(Windows Server 2025 / 96 DPI / RX 6600 XT):浅/深 × GPU/CPU 四张真开窗截图;GPU vs CPU
差异 0.72% 像素、全部落在字形/图标抗锯齿与 1px 圆角描边、最佳对齐 (0,0)、平坦区颜色逐字节相同;
release 体积双后端 6.44 MiB / 仅 CPU 3.75 MiB;首帧 GPU 1.18~1.31s、CPU 33~37ms。

未做/未验证:macOS 与 Wayland 全链路、西/北边缩放热区、letter spacing(iced 0.14 无此属性)、
中文 CDN 字体、玻璃;翻牌步骤写在 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md(不入库)。
2026-11-01 17:11:24 +08:00

311 lines
9.9 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** | `IconSize::Toolbar` + `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 nav;
pub use nav::NavItem;
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, mouse_area, row, space, 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::component_map::Component;
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::icons;
use crate::theme::Ui;
/// 整棵树。
pub fn view<'a>(
ui: &'a Ui,
selected: NavItem,
app_title: &'a str,
page_title: &'a str,
body: &'a str,
) -> Element<'a, Message> {
let work_area = column![
titlebar(ui, app_title),
row![nav::rail(ui, selected), content(ui, page_title, body)]
.width(Length::Fill)
.height(Length::Fill),
]
.width(Length::Fill)
.height(Length::Fill);
// 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。西/北边见模块文档的已知缺口。
let thickness = Length::Fixed(ui.space(Space::X1));
let shell = column![
row![
work_area,
resize_edge(
Direction::East,
(thickness, Length::Fill),
mouse::Interaction::ResizingHorizontally,
),
]
.width(Length::Fill)
.height(Length::Fill),
resize_edge(
Direction::South,
(Length::Fill, thickness),
mouse::Interaction::ResizingVertically,
),
]
.width(Length::Fill)
.height(Length::Fill);
container(shell)
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| 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) -> 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),
caption(ui, IconKind::Maximize, Message::ToggleMaximize),
caption(ui, IconKind::Close, Message::Close),
]
.width(Length::Fill)
.height(metrics.titlebar_height)
.align_y(Alignment::Center);
container(bar)
.width(Length::Fill)
.height(metrics.titlebar_height)
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
/// 标题栏按钮(自绘最小化/最大化/关闭)。
fn caption<'a>(ui: &'a Ui, kind: IconKind, message: Message) -> Element<'a, Message> {
let metrics = ui.metrics();
button(icons::view::<Message>(
kind,
ui.color(Role::OnSurfaceVariant),
))
.width(metrics.titlebar_button_width)
.height(metrics.titlebar_height)
.padding(0)
.style(move |_theme: &IcedTheme, status| {
let background = match 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()
}
// ---------------------------------------------------------------- 内容区
fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> {
let metrics = ui.metrics();
// 卡片档位由组件表决定(圆角/字级),组件层不自己挑
let card_spec = Component::OutlinedCard.spec();
let body_role = card_spec.support.unwrap_or(card_spec.text);
let header = container(label(
ui,
TypeRole::TitleLarge,
true,
title,
ui.color(Role::OnSurface),
))
.width(Length::Fill)
.height(metrics.page_header_height)
.align_y(Alignment::Center)
.padding(Padding::from([0.0, ui.space(Space::X6)]));
let card = container(
column![
label(
ui,
card_spec.text,
true,
"Shell ready",
ui.color(Role::OnSurface),
),
label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)),
]
.spacing(ui.space(Space::X2)),
)
.width(Length::Fill)
.padding(metrics.card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: ui.outlined(card_spec.shape),
..container::Style::default()
});
let body_area = container(card)
.width(Length::Fill)
.height(Length::Fill)
.padding(ui.space(Space::X6))
.align_y(Alignment::Start);
column![header, body_area]
.width(Length::Fill)
.height(Length::Fill)
.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()
}
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::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");
}
}