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(不入库)。
This commit is contained in:
1 parent
3fcdc3d3e1
commit
df4ff373b7
16 files changed
+4244
-97
No files matched your search
@@ -0,0 +1,310 @@
|
||||
//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。
|
||||
//!
|
||||
//! ## 与参考判据的对应(`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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! 左侧导航轨(M3 `NavigationRail`)。
|
||||
//!
|
||||
//! 判据来源 `research/12b-component-fidelity.md` §5:
|
||||
//!
|
||||
//! | 项 | 参考值 | 令牌落点 |
|
||||
//! |---|---|---|
|
||||
//! | 轨宽 | 5.1 官方 96(narrow 80);本项目 **88** | `LayoutMetrics::DESKTOP.nav_expanded_width` |
|
||||
//! | 轨容器 | 5.3 形状 None / L0 / `Surface` | `MaterialLayer::Canvas` |
|
||||
//! | 选中指示器 | 5.4 **56×32**、圆角 full、`SecondaryContainer` | [`indicator_size`](令牌推导) |
|
||||
//! | 图标 | 5.5 24px | `IconSize::Toolbar` |
|
||||
//! | 标签 | 5.5 `LabelMedium 12/16` | `TypeRole::LabelMedium` |
|
||||
//! | 颜色 | 5.6 选中图标 `OnSecondaryContainer` / 选中标签 `Secondary` / 未选中 `OnSurfaceVariant` | `Ui::color` |
|
||||
//! | 项间距 | 5.7 **4** | `LayoutMetrics::nav_item_gap` |
|
||||
|
||||
use iced::widget::{button, column, container, space};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Shadow, Theme as IcedTheme};
|
||||
|
||||
use unlucky_design::color::{Role, StateLayer};
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::icons;
|
||||
use crate::shell::{filled, label};
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NavItem {
|
||||
/// 实例列表。
|
||||
Instances,
|
||||
/// 下载/版本。
|
||||
Download,
|
||||
/// 日志。
|
||||
Logs,
|
||||
/// 设置。
|
||||
Settings,
|
||||
/// AI 助手。
|
||||
Ai,
|
||||
}
|
||||
|
||||
impl NavItem {
|
||||
/// 全部占位项(顺序 = 导航栏自上而下)。
|
||||
pub const ALL: [NavItem; 5] = [
|
||||
NavItem::Instances,
|
||||
NavItem::Download,
|
||||
NavItem::Logs,
|
||||
NavItem::Settings,
|
||||
NavItem::Ai,
|
||||
];
|
||||
|
||||
/// 显示名(i18n 是后续 mission;本 mission 用英文基线文案,避免干净 CI 上缺中文变豆腐块)。
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
NavItem::Instances => "Instances",
|
||||
NavItem::Download => "Download",
|
||||
NavItem::Logs => "Logs",
|
||||
NavItem::Settings => "Settings",
|
||||
NavItem::Ai => "Assistant",
|
||||
}
|
||||
}
|
||||
|
||||
/// 令牌图标。
|
||||
pub const fn icon(self) -> IconKind {
|
||||
match self {
|
||||
NavItem::Instances => IconKind::Instances,
|
||||
NavItem::Download => IconKind::Download,
|
||||
NavItem::Logs => IconKind::Logs,
|
||||
NavItem::Settings => IconKind::Settings,
|
||||
NavItem::Ai => IconKind::Ai,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 整条导航轨。
|
||||
pub(super) fn rail<'a>(ui: &'a Ui, selected: NavItem) -> Element<'a, Message> {
|
||||
let metrics = ui.metrics();
|
||||
let indicator = indicator_size(metrics);
|
||||
|
||||
let mut items: Vec<Element<'a, Message>> =
|
||||
vec![space::vertical().height(ui.space(Space::X6)).into()];
|
||||
|
||||
for item in NavItem::ALL {
|
||||
items.push(rail_item(ui, item, item == selected, indicator));
|
||||
}
|
||||
|
||||
container(column(items).spacing(metrics.nav_item_gap))
|
||||
.width(metrics.nav_expanded_width)
|
||||
.height(Length::Fill)
|
||||
.style(|_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn rail_item<'a>(
|
||||
ui: &'a Ui,
|
||||
item: NavItem,
|
||||
selected: bool,
|
||||
indicator: (f32, f32),
|
||||
) -> Element<'a, Message> {
|
||||
let (indicator_width, indicator_height) = indicator;
|
||||
|
||||
let icon_color = if selected {
|
||||
ui.color(Role::OnSecondaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
// 12b 5.6:选中标签用 `Secondary`,未选中 `OnSurfaceVariant`
|
||||
let label_color = if selected {
|
||||
ui.color(Role::Secondary)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
let pill = container(icons::view::<Message>(item.icon(), icon_color))
|
||||
.width(indicator_width)
|
||||
.height(indicator_height)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(move |_theme| container::Style {
|
||||
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, indicator_height),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
});
|
||||
|
||||
let item_height = nav_item_height(ui, indicator_height);
|
||||
let stack = column![
|
||||
pill,
|
||||
label(ui, TypeRole::LabelMedium, false, item.label(), label_color),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_x(Alignment::Center)
|
||||
.height(item_height);
|
||||
|
||||
button(stack)
|
||||
.width(ui.metrics().nav_expanded_width)
|
||||
.height(item_height)
|
||||
.padding(0)
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = match status {
|
||||
button::Status::Hovered if !selected => Some(Background::Color(ui.state_over(
|
||||
StateLayer::Hover,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
))),
|
||||
button::Status::Pressed if !selected => Some(Background::Color(ui.state_over(
|
||||
StateLayer::Pressed,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
))),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background,
|
||||
text_color: label_color,
|
||||
border: Border::default(),
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
})
|
||||
.on_press(Message::Select(item))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死:
|
||||
///
|
||||
/// - 宽 = `nav_expanded_width − 2 × Space::X4` = 88 − 32 = **56**(轨的左右内边距 16)
|
||||
/// - 高 = `control_height − Space::X2` = 40 − 8 = **32**(可见高度上下各让 4)
|
||||
pub fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) {
|
||||
(
|
||||
metrics.nav_expanded_width - Space::X4.dp() * 2.0,
|
||||
metrics.control_height - Space::X2.dp(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏,
|
||||
/// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。
|
||||
pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 {
|
||||
indicator_height + ui.space(Space::X1) + ui.style(TypeRole::LabelMedium).line_height_px
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
fn ui() -> Ui {
|
||||
Ui::with_platform(
|
||||
SchemeFlavor::Light,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indicator_matches_the_reference_56x32() {
|
||||
let (width, height) = indicator_size(LayoutMetrics::DESKTOP);
|
||||
assert_eq!((width, height), (56.0, 32.0), "12b 5.4 的参考值");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_item_height_is_indicator_plus_gap_plus_label() {
|
||||
let ui = ui();
|
||||
let (_, indicator_height) = indicator_size(ui.metrics());
|
||||
// 32 + 4 + 16
|
||||
assert_eq!(nav_item_height(&ui, indicator_height), 52.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_nav_item_has_a_label_and_an_icon() {
|
||||
assert!((4..=6).contains(&NavItem::ALL.len()));
|
||||
for item in NavItem::ALL {
|
||||
assert!(!item.label().is_empty(), "{item:?} 没有文案");
|
||||
assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user