//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。 //! //! ## 与参考判据的对应(`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::( 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"); } }