Files
Unlucky-Launcher/crates/app-iced/src/theme.rs
T
AskaEth 14282f3325 fix(app-iced): 复审 r1 的四条 p2 + 三条小事(M250)
- p2-1 删死状态:`Accent::scheme` 字段 / `scheme()` 取值口 / `set_flavor` 重派生全仓零读者,
  且注释谎称「Ui 的唯一入口」。色值 `color` 与深浅无关,`Ui` 由 `rebuild_ui` 重算,
  所以整条删掉,`new/set/reset` 也不再收 `flavor`
- p2-2 规则只定义一次:新增 `theme::tokens(flavor, accent, scheme, families) -> (Ui, Colors)`,
  `App::rebuild_ui` 与出图 harness 都调它(harness 传 scheme=None,如实说明不覆盖 D38 分支);
  README 改成「同一条规则、不是同一个调用点」
- p2-3 逐键不闪红:`Accent` 区分「显示中的错」与 `pending`。只有**不可能再变合法**(非 hex / 超 6 位)
  才立刻亮;位数不足先挂 pending,等 `commit()`(点到别的外观控件)或配置读回才亮
- p2-4 订正 `app/state.rs:151` 那句反话(强调色现在是 UI 写入字段)
- 小事:hint 文案改「中性表面与错误色不随强调色变」;`Digit{ch}` 里的不可见字符显示成 U+XXXX;
  8 颗色块补 tooltip(hex 原文,Overlay 底)

判据:新用例 `accent_errors_only_appear_when_they_are_actionable` 覆盖「输入中不亮 / 提交后亮 /
配置读回立刻亮」三态;gallery 出图逐字节与重构前相同(规则搬迁没改像素)。
2026-11-11 15:46:10 +08:00

387 lines
16 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.
//! # 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。
//!
//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方(M61 §0):组件层只调
//! [`Ui`],绝不出现色值/字号/半径/间距字面量(`tests/no_hardcoded_tokens.rs` 扫 `src/**`)。
//!
//! ## 两套来源:品牌色板(出厂)与取色方案(D38)
//!
//! M229 起 [`Ui::new`] 走 **品牌色板**([`BrandBoard`]:Ink = 深色主方案 / Silver = 浅色主方案),
//! `Scheme::baseline`(tone 派生)退回它本来的角色——D38 取色链的中性锚点。两条路径并列:
//!
//! - [`Ui::brand`]:出厂外观,色板自己决定深浅([`BrandBoard::flavor`]);
//! - [`Ui::with_accent`]:用户自定义强调色接管 primary/secondary/tertiary 三族(M250),
//! 中性表面与 error 保持基线;
//! - [`Ui::from_scheme`]:背景图 / 壁纸取色成功时接管 49 个角色(取色失败回落色板);
//! - [`Ui::baseline`]:老的 tone 派生口径,只给对照与测试用(产品路径不再默认走它)。
//!
//! 「跟随系统」是**设置层的第三种态**,不是第三套板:系统浅色 → Silver、系统深色 → Ink
//! ([`BrandBoard::for_flavor`])。色板始终只有两套。
//!
//! ## 为什么不用 `iced::theme::Base` 承载 49 个角色
//!
//! iced 的 `Palette` 只有 6 色,M3 有 49 个角色(M60 Q5):真源是本模块的 [`Scheme`],`Theme`
//! 只用来「换名字」(名字变了 iced 才重算缓存),控件外观由各自的 `Style` 注入。
//! `success`/`warning` 没有对应角色,按邻近语义映射(→ `Secondary` / `Tertiary`),不许读。
use iced::border::Radius;
use iced::theme::Palette;
use iced::{Border, Color, Font, Theme};
use unlucky_design::color::{BrandBoard, Role, Scheme, SchemeFlavor, StateLayer};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::inkcat::metrics::{self as inkcat, SpecBox};
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight};
/// 全站描边宽度(rubric G7:1px)。令牌层的 `stroke::HAIRLINE` 是唯一定义点,这里只再导出。
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` 并整树重绘,
/// 控件里不要缓存颜色。
#[derive(Debug, Clone)]
pub struct Ui {
scheme: Scheme,
/// 当前色板(Ink / Silver)。`from_scheme` 时取方案深浅对应的那块,`name()` 与图例用它。
board: BrandBoard,
platform: Platform,
metrics: LayoutMetrics,
families: Families,
/// 面板层不透明度(D36 / M93):**唯一的 α 来源**,由 [`Ui::layer`] 统一施加。
panel_alpha: f32,
}
impl Ui {
/// 按平台(由 `cfg!` 决定)与**品牌色板**求值:深浅决定色板(浅 → Silver / 深 → Ink)。
///
/// 这是产品出厂外观的唯一入口(M229):`DesignTokens::brand(BrandBoard::for_flavor(flavor))`。
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::with_board(BrandBoard::for_flavor(flavor), platform, families)
}
/// 按**指定色板**求值(色板自己决定深浅,见 [`BrandBoard::flavor`])。
pub fn brand(board: BrandBoard, families: Families) -> Self {
Self::with_board(board, Platform::current(), families)
}
/// 指定色板 + 平台(截图与测试用)。
pub fn with_board(board: BrandBoard, platform: Platform, families: Families) -> Self {
Self {
scheme: board.scheme(),
board,
platform,
metrics: LayoutMetrics::DESKTOP,
families,
panel_alpha: 1.0,
}
}
/// tone 派生的 `Scheme::baseline` 口径(D38 取色链的中性锚点;产品路径不再默认走它)。
///
/// 留成公开入口是为了对照与测试:`Ui::new(flavor)` 与它在**颜色上不同**,那是 M229 的换肤。
pub fn baseline(flavor: SchemeFlavor, families: Families) -> Self {
Self::from_scheme(Scheme::baseline(flavor), families)
}
/// 当前深浅。
pub fn flavor(&self) -> SchemeFlavor {
self.scheme.flavor()
}
/// 当前色板(Ink / Silver)。
pub fn board(&self) -> BrandBoard {
self.board
}
/// 用**已解析的方案**构造求值器(D38:背景图 / 壁纸取色 → 49 个角色换掉色板)。
///
/// 色板字段取方案深浅对应的那块(`Light → Silver` / `Dark → Ink`):取色方案只换颜色,
/// 不改变「这一屏是深色还是浅色」,所以 `name()` 仍然说实话。
pub fn from_scheme(scheme: Scheme, families: Families) -> Self {
Self {
board: BrandBoard::for_flavor(scheme.flavor()),
scheme,
platform: Platform::current(),
metrics: LayoutMetrics::DESKTOP,
families,
panel_alpha: 1.0,
}
}
/// 按**用户自定义强调色**求值(M250 ACCENTWIRE):`DesignTokens::with_accent` 的 iced 侧入口。
///
/// 语义来自 design 的 [`Scheme::from_accent`](既定产品口径,不是缺口):只换
/// primary / secondary / tertiary 三族,**中性表面与 error 保持基线**——「换强调色不该让整个
/// 窗口底色变脏」。所以拿它跟 [`Ui::new`] 比,Primary 类角色会不同,Surface 系列**不会**。
pub fn with_accent(
flavor: SchemeFlavor,
accent: unlucky_design::color::Color,
families: Families,
) -> Self {
Self::from_scheme(accent_scheme(flavor, accent), families)
}
/// 当前平台(决定字体族与字重降级)。
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))
}
/// 当前生效的 **49 角色方案**(品牌色板 / D38 取色方案,二选一的那一份)。
///
/// 凡是要**在令牌侧合成颜色**的地方(禁用态 38% 叠底、对话框遮罩 scrim、状态层叠色)都必须
/// 从这里取,**不许**再 `Scheme::baseline(self.flavor())` 重算:
///
/// - 重算出来的永远是 tone 派生的旧色(天蓝中性),品牌板上的禁用滑块轨道、填充框、遮罩会
/// 各画各的(M229 复审 F1 实测:Ink 下三个蓝系像素值,应为绿系去饱和、遮罩为中性
/// 绿黑;具体数值与出处见 `DevDocs/artifacts/m229/README.md` §6)。
/// - 取色方案(D38)成功时也必须同源,否则「取色接管 49 个角色」这件事在禁用态上破一个洞。
pub fn scheme(&self) -> &Scheme {
&self.scheme
}
/// 材质层(Canvas/Card/Overlay)→ 容器色;**只有令牌允许半透明的层才乘面板 α**(M93 唯一来源)。
///
/// α 乘在这里而不是调用点:所有表面色都从这里出去(根容器/标题栏/导航轨/页头带/卡片),
/// 一次同源、调用点无从「各自自乘」。Canvas 层恒不透明(r1 P2-2):令牌
/// [`MaterialLayer::allows_translucency`] 写死 `Canvas => false`(窗口底),而三个窗口键与
/// 导航轨、页头带都取 Canvas——给它们乘 α 会让文字直接压在背景照片上(α=0.55 实测不可读)。
pub fn layer(&self, layer: MaterialLayer) -> Color {
let base = self.color(layer.surface_role());
if layer.allows_translucency() {
base.scale_alpha(self.panel_alpha)
} else {
base
}
}
/// 当前面板 α(CPU 档由 `app` 侧强制成 1.0)。
pub fn panel_alpha(&self) -> f32 {
self.panel_alpha
}
/// 换一个面板 α(只影响 `layer()` 出的表面色,不动角色色 `color()`;默认 `1.0`)。
#[must_use]
pub fn with_panel_alpha(mut self, alpha: f32) -> Self {
self.panel_alpha = if alpha.is_finite() {
alpha.clamp(0.0, 1.0)
} else {
1.0
};
self
}
/// 状态层叠在容器色上,返回**不透明**结果(D27: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` = 控件在该轴上的边长,`Full` 要按它折算)。
pub fn radius(&self, shape: ShapeScale, side: f32) -> Radius {
Radius::new(shape.radius_px(side))
}
/// **规格卡口径的卡片方框**(`ELEVATED CARD` / `CARD`,`inkcat::metrics::CARD`)。
///
/// 与 `component_map` 的 `OutlinedCard = Medium(12)` 差 4:规格卡画的是 `rx=16`(Large),
/// M228 的 `inkcat` 模块把这条差异登记为「换肤时取 `CARD`」,M229 就是那次换肤。表值不动
/// (它还服务别的面),页面卡片统一从这里取。
pub fn card_box(&self) -> SpecBox {
inkcat::CARD
}
/// 卡片圆角档(= [`Self::card_box`] 的 `shape`,即 `ShapeScale::Large` = 16)。
pub fn card_shape(&self) -> ShapeScale {
self.card_box().shape
}
/// 固定档圆角。`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()` 的取值(稳定字符串,改名要先在协作文档登记)。
///
/// M229 起按**色板**取名(`ull-ink` / `ull-silver`):换肤之后「深色」这个词不再等于
/// 「M3 深色 tone 派生」,名字必须能区分「是哪块板」。D38 取色方案仍叫它所属深浅的那块
/// (取色只换颜色,不换深浅)。
pub fn name(&self) -> &'static str {
match self.board {
BrandBoard::Ink => "ull-ink",
BrandBoard::Silver => "ull-silver",
}
}
}
/// 令牌色(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),
)
}
/// 强调色 → 49 角色方案:`DesignTokens::with_accent` 的**唯一**调用点(M250)。
///
/// 放在适配层而不是调用点,是因为「强调色只换三族、中性色保持基线」这条语义属于令牌层;
/// 页面侧只该拿到「一套方案」,不该各自决定换哪几个角色。
pub fn accent_scheme(flavor: SchemeFlavor, accent: unlucky_design::color::Color) -> Scheme {
unlucky_design::DesignTokens::with_accent(flavor, accent).colors
}
/// 这一屏的颜色从哪来(M250)。日志与出图都靠它说清「是谁生效」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Colors {
/// 用户显式填的强调色(`appearance.accent_color`)。
Accent,
/// D38 取色方案(背景图 / 壁纸取色成功,`State::derived_scheme`)。
Scheme,
/// 出厂品牌色板(`DesignTokens::brand`)。
Board,
}
/// 强调色 / 取色方案 / 出厂板 **三选一**:全仓**唯一**的优先级定义点(M250)。
///
/// 产品路径(`App::rebuild_ui`)与出图 harness(`pages::harness`)都调它。为什么必须有这一个
/// 函数:两头各抄一份两选一/三选一,迟早出现「出图上有强调色、产品里没有」或者反过来,而那种
/// 偏差在图上看不出来(图是对的,产品是错的)。
///
/// 优先级:**显式强调色 > D38 取色方案 > 出厂板**。手填的色是直接意图,取色是从图片派生的。
/// `flavor`(明暗 / 跟随系统)不在这里解析,由调用方按现有主题逻辑给。
pub fn tokens(
flavor: SchemeFlavor,
accent: Option<unlucky_design::color::Color>,
scheme: Option<Scheme>,
families: Families,
) -> (Ui, Colors) {
if let Some(accent) = accent {
(Ui::with_accent(flavor, accent, families), Colors::Accent)
} else if let Some(scheme) = scheme {
(Ui::from_scheme(scheme, families), Colors::Scheme)
} else {
(Ui::new(flavor, families), Colors::Board)
}
}
/// 令牌字重 → 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;