//! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。 //! //! ## 边界(M61 `design-令牌约定.md` §0 的落地) //! //! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方。组件层(`shell`) //! 只调 [`Ui`] 的方法,绝不出现色值 / 字号 / 半径 / 间距字面量——`tests/no_hardcoded_tokens.rs` //! 会扫 `src/**` 做守门。 //! //! ## 为什么不用 `iced::theme::Base` 自定义主题承载 49 个角色 //! //! `iced` 的 `Palette` 只有 6 个颜色(`background/text/primary/success/warning/danger`), //! M3 有 49 个角色,塞不进去(M60 Q5 + M61 §9-0 的同一结论)。所以: //! //! - **真源**是本模块持有的 [`Scheme`](49 角色); //! - iced 的 `Theme` 只用来「换名字」——`Theme::name()` 变了 iced 才会重算受主题影响的缓存 //! (M61 §9 的最后一条)。`palette()` 只给 devtools 看,控件外观一律由各自的 `Style` 注入。 //! //! `success` / `warning` 在 M3 里**没有对应角色**(`design-令牌约定.md` §2 明写), //! 这里按「语义最近的现成角色」映射:`success → Secondary`(不是「成功」的承诺)、 //! `warning → Tertiary`(M3 把「提醒」放 Tertiary)。任何控件都不要去读这两个槽位。 use iced::border::Radius; use iced::theme::Palette; use iced::{Border, Color, Font, Theme}; use unlucky_design::color::{Role, Scheme, SchemeFlavor, StateLayer}; use unlucky_design::elevation::MaterialLayer; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::{LayoutMetrics, Space}; use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight}; /// 全站描边宽度(逻辑 px @100% DPI)。rubric G7 要求全站 1px —— 令牌层的「一像素线单一真源」。 /// /// M69 起令牌层提供 `unlucky_design::stroke::HAIRLINE`(容器描边/分隔线/聚焦环共用), /// 这里**再导出**而不是自己写 `1.0`:全仓只有令牌层一个定义点。 pub use unlucky_design::stroke::HAIRLINE as STROKE_PX; /// 已经解析到系统的字体族名(`&'static str` 才能塞进 `iced::Font`)。 /// /// 由 `fonts` 模块按令牌栈 + 系统字体库求值,本模块只消费。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Families { /// 正文族(`FontFamily::Text` 栈里第一个装机可用的)。 pub text: &'static str, /// 展示族(`FontFamily::Display`)。 pub display: &'static str, /// 等宽族(`FontFamily::Mono`,日志/版本号用)。 pub mono: &'static str, } /// 令牌求值器:一个 `Ui` 对应一套主题(浅或深)。 /// /// 深浅热切 = 重新 `Ui::new(flavor, families)` 并整树重绘;控件里不要缓存颜色。 #[derive(Debug, Clone)] pub struct Ui { scheme: Scheme, platform: Platform, metrics: LayoutMetrics, families: Families, } impl Ui { /// 按平台(由 `cfg!` 决定)与令牌基线方案求值。 pub fn new(flavor: SchemeFlavor, families: Families) -> Self { Self::with_platform(flavor, Platform::current(), families) } /// 显式指定平台(跨平台核对与测试用)。 pub fn with_platform(flavor: SchemeFlavor, platform: Platform, families: Families) -> Self { Self { scheme: Scheme::baseline(flavor), platform, metrics: LayoutMetrics::DESKTOP, families, } } /// 当前深浅模式。 pub fn flavor(&self) -> SchemeFlavor { self.scheme.flavor() } /// 当前平台(决定字体族与字重降级)。 pub fn platform(&self) -> Platform { self.platform } /// 骨架尺寸(标题栏/导航/行高…)。 pub fn metrics(&self) -> LayoutMetrics { self.metrics } /// 已解析的字体族名。 pub fn families(&self) -> Families { self.families } /// 间距档位(4px 栅格)。 pub fn space(&self, space: Space) -> f32 { space.dp() } /// M3 角色 → iced 颜色。 pub fn color(&self, role: Role) -> Color { to_iced(self.scheme.get(role)) } /// 材质层(Canvas/Card/Overlay)→ 容器色。 pub fn layer(&self, layer: MaterialLayer) -> Color { self.color(layer.surface_role()) } /// 状态层叠在容器色上,返回**不透明**结果。 /// /// 为什么不用带 alpha 的叠加:D27 要求 CPU 档不出现半透明,而「不透明结果」让 GPU/CPU /// 两档的颜色也逐像素一致(只有材质/玻璃才允许两档不同)。 pub fn state_over(&self, state: StateLayer, content: Role, container: Role) -> Color { to_iced(state.over(self.scheme.get(content), self.scheme.get(container))) } /// 圆角(令牌刻度);`side` 是控件在该轴上的边长(`ShapeScale::Full` 要按边长折算)。 pub fn radius(&self, shape: ShapeScale, side: f32) -> Radius { Radius::new(shape.radius_px(side)) } /// 固定档圆角(不需要边长)。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。 pub fn radius_dp(&self, shape: ShapeScale) -> Radius { debug_assert!( !matches!(shape, ShapeScale::Full), "Full 需要边长,请用 Ui::radius(shape, side)" ); Radius::new(if matches!(shape, ShapeScale::Full) { 0.0 } else { shape.dp() }) } /// 描边:`OutlineVariant` + [`STROKE_PX`],圆角走刻度(固定档)。 pub fn outlined(&self, shape: ShapeScale) -> Border { Border { color: self.color(Role::OutlineVariant), width: STROKE_PX, radius: self.radius_dp(shape), } } /// baseline 排版档。 pub fn style(&self, role: TypeRole) -> TypeStyle { role.style() } /// Expressive 强调档(官方值:字重升到 500/700)。 pub fn emphasized(&self, role: TypeRole) -> TypeStyle { role.emphasized() } /// 某个排版档对应的 iced 字体(族 + 字重)。 pub fn font_of(&self, style: TypeStyle) -> Font { let family = match style.family { FontFamily::Display => self.families.display, FontFamily::Text => self.families.text, FontFamily::Mono => self.families.mono, }; Font { family: iced::font::Family::Name(family), weight: weight_of(style.weight), ..Font::DEFAULT } } /// baseline 档的字体。 pub fn font(&self, role: TypeRole) -> Font { self.font_of(self.style(role)) } /// 强调档的字体。 pub fn emphasized_font(&self, role: TypeRole) -> Font { self.font_of(self.emphasized(role)) } /// iced 的 6 色 `Palette`(只给 devtools / 兜底控件用;M3 的 49 角色在 [`Ui::color`])。 pub fn palette(&self) -> Palette { Palette { background: self.color(Role::Surface), text: self.color(Role::OnSurface), primary: self.color(Role::Primary), success: self.color(Role::Secondary), warning: self.color(Role::Tertiary), danger: self.color(Role::Error), } } /// iced 主题对象:**名字带深浅**,切主题时 `Theme::name()` 变化会触发重算。 pub fn theme(&self) -> Theme { Theme::custom(self.name(), self.palette()) } /// `Theme::name()` 的取值(稳定字符串,改名要先在协作文档登记)。 pub fn name(&self) -> &'static str { match self.flavor() { SchemeFlavor::Light => "ull-m3-light", SchemeFlavor::Dark => "ull-m3-dark", } } } /// 令牌色(8 bit sRGB)→ iced 的 sRGB 分量。 pub fn to_iced(color: unlucky_design::color::Color) -> Color { Color::from_rgba8( color.r, color.g, color.b, f32::from(color.a) / f32::from(u8::MAX), ) } /// 令牌字重 → iced 字重。 pub fn weight_of(weight: Weight) -> iced::font::Weight { match weight { Weight::Regular => iced::font::Weight::Normal, Weight::Medium => iced::font::Weight::Medium, Weight::SemiBold => iced::font::Weight::Semibold, Weight::Bold => iced::font::Weight::Bold, } } #[cfg(test)] mod tests { use super::*; const FAMILIES: Families = Families { text: "Segoe UI Variable Text", display: "Segoe UI Variable Display", mono: "Cascadia Mono", }; fn ui(flavor: SchemeFlavor) -> Ui { Ui::with_platform(flavor, Platform::Windows, FAMILIES) } #[test] fn theme_name_tracks_the_flavor() { assert_eq!(ui(SchemeFlavor::Light).name(), "ull-m3-light"); assert_eq!(ui(SchemeFlavor::Dark).name(), "ull-m3-dark"); assert_ne!( ui(SchemeFlavor::Light).theme().to_string(), ui(SchemeFlavor::Dark).theme().to_string() ); } #[test] fn canvas_card_overlay_follow_surface_containers() { let ui = ui(SchemeFlavor::Light); assert_eq!(ui.layer(MaterialLayer::Canvas), ui.color(Role::Surface)); assert_eq!( ui.layer(MaterialLayer::Card), ui.color(Role::SurfaceContainerLow) ); assert_eq!( ui.layer(MaterialLayer::Overlay), ui.color(Role::SurfaceContainerHigh) ); } #[test] fn radii_only_come_from_the_scale() { let ui = ui(SchemeFlavor::Light); // 卡片 Medium(12):边长够大时就是 12 assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0); assert_eq!(ui.radius_dp(ShapeScale::ExtraSmall).top_left, 4.0); // 药丸 Full:按边长一半折算 assert_eq!(ui.radius(ShapeScale::Full, 32.0).top_left, 16.0); // 不许超过边长一半 assert_eq!(ui.radius(ShapeScale::ExtraLarge, 20.0).top_left, 10.0); } #[test] fn outlines_are_one_pixel_and_use_outline_variant() { let ui = ui(SchemeFlavor::Light); let border = ui.outlined(ShapeScale::Medium); assert_eq!(border.width, STROKE_PX); assert_eq!(border.width, 1.0, "rubric G7:全站 1px"); assert_eq!(border.color, ui.color(Role::OutlineVariant)); assert_eq!(border.radius.top_left, 12.0); } #[test] fn emphasized_weights_are_the_official_ones() { let ui = ui(SchemeFlavor::Light); // title-small 的强调档是 700(官方 Emphasized) assert_eq!( ui.emphasized_font(TypeRole::TitleSmall).weight, iced::font::Weight::Bold ); // baseline 正文是 400 assert_eq!( ui.font(TypeRole::BodyMedium).weight, iced::font::Weight::Normal ); } #[test] fn body_text_keeps_wcag_contrast_in_both_flavors() { for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { let scheme = Scheme::baseline(flavor); assert!( scheme.contrast(Role::OnSurface, Role::Surface) >= 4.5, "{flavor:?} 的正文对比度不够" ); assert!( scheme.contrast(Role::OnSecondaryContainer, Role::SecondaryContainer) >= 4.5, "{flavor:?} 的选中态药丸对比度不够" ); } } #[test] fn every_role_maps_to_an_iced_color() { for role in Role::ALL { let color = ui(SchemeFlavor::Dark).color(role); assert!((0.0..=1.0).contains(&color.a), "{role:?} 的 alpha 越界"); } } #[test] fn state_layers_are_opaque_so_cpu_and_gpu_match() { let ui = ui(SchemeFlavor::Light); let hovered = ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface); assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果"); assert_ne!(hovered, ui.color(Role::Surface)); } }