From 31ee69100a74e54e17ad08760041bc92696b48e9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 09:34:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(design):=20resolve=20=E6=8B=86?= =?UTF-8?q?=E6=88=90=E7=9B=AE=E5=BD=95=E6=A8=A1=E5=9D=97=EF=BC=88=E5=8D=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6=20=E2=89=A4400=20=E8=A1=8C=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=96=87=E6=A1=A3=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - palette/resolve/{mod.rs,provenance.rs}:编排函数与 Provenance 类型分开,路径不变 - color/mod.rs + palettes.rs:原来的「取色/色卡库不在本 crate、还没实现」改成指向 crate::palette - 取色 gap 登记改为「单种子已实现,多 source 色仍未实现」 --- crates/design/src/color/mod.rs | 7 +- crates/design/src/color/palettes.rs | 11 +- .../palette/{resolve.rs => resolve/mod.rs} | 236 +---------------- .../design/src/palette/resolve/provenance.rs | 237 ++++++++++++++++++ 4 files changed, 261 insertions(+), 230 deletions(-) rename crates/design/src/palette/{resolve.rs => resolve/mod.rs} (60%) create mode 100644 crates/design/src/palette/resolve/provenance.rs diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs index b09f830..73af9ad 100644 --- a/crates/design/src/color/mod.rs +++ b/crates/design/src/color/mod.rs @@ -41,7 +41,7 @@ //! |---|---|---| //! | 对比度三档(default/medium/high) | `ContrastCurve` + `contrastLevel` | **未实现**:只做 `contrastLevel = 0`。在 0 档下官方那层「tone 差对 + 对比度修正」机制的输入全是常数,等价于本模块的静态 tone 表(已由对齐测试反证) | //! | 其他配色变体 | `Vibrant` / `Expressive` / `Fidelity` / `Content` / `Monochrome` / `Neutral` / `Rainbow` / `FruitSalad` / `CMF` | **未实现**:只有 `TonalSpot`(M3 默认、也是 Material Theme Builder 默认) | -//! | 壁纸多色取色 | `QuantizerCelebi` / `Score` / 多 source 色 | **未实现**:桌面启动器用不到(安卓端特性),也避免引入图像依赖 | +//! | 壁纸多色取色 | `QuantizerCelebi` / `Score` / 多 source 色 | **单种子部分已实现**:量化(5 bit 直方图 + CIELAB k-means)与 `Score` 挑主色在 [`crate::palette`](M87)。**多 source 色**(一张图出 4 个种子、每个 `*_container` 换色相)仍未实现——产品口径是「一套主题一个强调色」(D38) | //! | 换观察条件 | `Hct.inViewingConditions` | **未实现**:只有 sRGB 屏的默认条件(D65 / 200 lux / 背景 `L* 50`) | //! //! ## 「自定义强调色」与官方 `from_seed` 的关系 @@ -65,8 +65,9 @@ //! //! **定位(D38)**:这套默认只是「**莫奈关闭时的出厂预选色卡**」(首次启动、用户还没选过色卡时 //! 用的那一套),**不是唯一的配色来源**。用户的配色来源有三级优先:① 启动器背景图取色(Monet) -//! → ② 桌面壁纸取色 → ③ 莫奈关闭(默认)时从内置多套色卡里选。取色与「内置色卡库」都不在本 crate, -//! 也还没实现;本模块只负责「给定一个种子 + 中性彩度,派生出 49 个角色」。 +//! → ② 桌面壁纸取色 → ③ 莫奈关闭(默认)时从内置多套色卡里选。取色(量化 + 挑主色)、内置色卡库 +//! 与三级回落都在 [`crate::palette`](M87 落地,D40 分层);**本模块只负责**「给定种子 + 中性彩度, +//! 派生出 49 个角色」,不碰像素、不碰路径、不碰回落决策。 //! //! > ⚠️ **有意偏离登记(两处,同一处偏离的两半)**: //! > diff --git a/crates/design/src/color/palettes.rs b/crates/design/src/color/palettes.rs index 6b78e49..08da0aa 100644 --- a/crates/design/src/color/palettes.rs +++ b/crates/design/src/color/palettes.rs @@ -40,8 +40,10 @@ pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4); /// /// 用户定了三级优先的配色来源:① 启动器背景图取色(Monet)→ ② 桌面壁纸取色 → ③ 莫奈关闭 /// (默认)时用户从内置多套色卡里选。本常量 + [`DEFAULT_NEUTRAL_CHROMA`] 只定义**第三级里 -/// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套)。取色(Monet / 壁纸 / -/// `QuantizerCelebi`)与「内置多色卡库」都不在本 crate,也还没实现。 +/// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套;`palette::DEFAULT_CARD` 的种子即本值)。 +/// +/// 取色(量化 + 挑主色)、内置色卡库与三级回落都在 [`crate::palette`](M87,分层见 D40); +/// 本模块只负责「给定种子 + 中性彩度,派生出 49 个角色」。 pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC); /// 产品出厂默认的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。 @@ -54,8 +56,9 @@ pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC); /// 13 时 nv = 15 仍低于 secondary 的 16;**14 才会撞上**(nv = 16 = secondary),届时要一并改 /// `NEUTRAL_VARIANT_OFFSET`(私有常量)的规则。nv 的那一半偏离见 `color` 模块文档的偏离登记。 /// -/// 同样只服务于「出厂预选色卡」(D38 第三级),不约束将来的取色结果。将来若做「内置多套色卡」, -/// 每套色卡各带自己的中性彩度更合适——本常量到时候会退化成「默认那套的取值」。 +/// 同样只服务于「出厂预选色卡」(D38 第三级),不约束将来的取色结果。 +/// 内置多套色卡落地时每张卡各带自己的中性彩度(`palette::Card::neutral_chroma`),八张卡 +/// 当前都取本值——换卡只换色相,不让窗口底色的冷度跟着跳。 pub const DEFAULT_NEUTRAL_CHROMA: f64 = 12.0; /// 调色板槽位(M3 的六条 tonal palette)。 diff --git a/crates/design/src/palette/resolve.rs b/crates/design/src/palette/resolve/mod.rs similarity index 60% rename from crates/design/src/palette/resolve.rs rename to crates/design/src/palette/resolve/mod.rs index 8bc4002..a09621e 100644 --- a/crates/design/src/palette/resolve.rs +++ b/crates/design/src/palette/resolve/mod.rs @@ -32,11 +32,21 @@ //! 2. **不许静默变黑白**。彩度不足的图**不**会被「压成灰方案」,而是直接回落到用户选的那张色卡。 //! 3. **不许静默换成默认卡**。回落的落点是「用户当前选中的卡」(参数 `preset`), //! 只有用户从没选过卡(`None`)才用 [`super::DEFAULT_CARD`]。 +//! +//! ## 文件划分 +//! +//! [`provenance`] 放「结果解释」的数据类型(`Level` / `Outcome` / `Skip` / `Provenance`), +//! 本文件放编排函数与测试。拆开只是为了单文件 ≤400 行的纪律,对外路径仍是 +//! `palette::resolve::*`。 use super::card::{Card, CardKey, DEFAULT_CARD}; -use super::seed::{analyze_rgba, Failure, Seed}; +use super::seed::{analyze_rgba, Seed}; use super::wcag::{check, converge, Adjustment}; -use crate::color::{Color, Scheme, SchemeFlavor}; +use crate::color::{Scheme, SchemeFlavor}; + +mod provenance; + +pub use provenance::{Level, Outcome, Provenance, Skip}; /// 已解码的图片:借用像素,不拷贝(解码在 `crates/palette-extract`)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -70,227 +80,6 @@ impl<'a> ImageInput<'a> { }) } } - -/// 方案的来源层级(D38 的三级)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Level { - /// ① 启动器背景图取色。 - BackgroundImage, - /// ② 桌面壁纸取色。 - Wallpaper, - /// ③ 内置色卡。 - Preset, -} - -impl Level { - /// 稳定码(日志/设置里用,别用 `Debug` 输出)。 - pub const fn code(self) -> &'static str { - match self { - Level::BackgroundImage => "background-image", - Level::Wallpaper => "wallpaper", - Level::Preset => "preset", - } - } - - /// 在本模块内部当索引用(0 = ①)。 - const fn index(self) -> usize { - match self { - Level::BackgroundImage => 0, - Level::Wallpaper => 1, - Level::Preset => 2, - } - } -} - -/// 某一级的处置结果。 -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Outcome { - /// 用了这一级。 - Used, - /// 用户没提供这一级(没设背景图 / 没开莫奈取色)。 - Absent, - /// 有输入但不合格,已跳过。 - Failed(Skip), -} - -/// 「有输入但不合格」的具体原因——每一条都要能变成一句人话。 -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Skip { - /// 解码失败(原因在适配层,这里只知道失败)。 - Undecodable, - /// 图太小(< 4 像素)。 - TooSmall { - /// 宽。 - width: u32, - /// 高。 - height: u32, - }, - /// 像素缓冲长度与宽高不符(调用方给错了数据)。 - BadDimensions { - /// 期望字节数。 - expected: usize, - /// 实际字节数。 - actual: usize, - }, - /// 全是透明像素。 - NoOpaquePixels, - /// 彩度不足(D40:`< MIN_SEED_CHROMA`),硬取色会凭空上色。 - LowChroma { - /// 实测彩度。 - chroma: f64, - /// 最接近的候选色(提示文案里显示成色块)。 - color: Color, - }, - /// 取色成功但对比度收敛后仍不达标。 - Contrast { - /// 仍不达标的对数。 - failures: usize, - }, -} - -impl Skip { - /// 稳定原因码(`Provenance::hint_key` 的后半段)。 - pub const fn code(self) -> &'static str { - match self { - Skip::Undecodable => "undecodable", - Skip::TooSmall { .. } => "too-small", - Skip::BadDimensions { .. } => "bad-dimensions", - Skip::NoOpaquePixels => "no-opaque-pixels", - Skip::LowChroma { .. } => "low-chroma", - Skip::Contrast { .. } => "contrast", - } - } -} - -impl From for Skip { - fn from(failure: Failure) -> Self { - match failure { - Failure::BadDimensions { - expected, actual, .. - } => Skip::BadDimensions { expected, actual }, - Failure::TooSmall { width, height } => Skip::TooSmall { width, height }, - Failure::NoOpaquePixels => Skip::NoOpaquePixels, - Failure::LowChroma { color, chroma } => Skip::LowChroma { color, chroma }, - } - } -} - -/// 这次配色「最后用了什么、前几级为什么没用」。 -#[derive(Debug, Clone, PartialEq)] -pub struct Provenance { - /// 最终生效的层级。 - pub level: Level, - /// 当前生效的色卡(取色成功时它仍然参与:中性彩度来自它)。 - pub card: CardKey, - /// 取色成功时的种子(色卡档为 `None`)。 - pub seed: Option, - /// 深浅模式。 - pub flavor: SchemeFlavor, - /// 对比度收敛改了哪些角色(空 = 没动)。 - pub adjustments: Vec, - outcomes: [Outcome; 3], -} - -impl Provenance { - /// 某一级发生了什么。 - pub fn outcome(&self, level: Level) -> Outcome { - self.outcomes[level.index()] - } - - /// 背景图那一级。 - pub fn background(&self) -> Outcome { - self.outcome(Level::BackgroundImage) - } - - /// 壁纸那一级。 - pub fn wallpaper(&self) -> Outcome { - self.outcome(Level::Wallpaper) - } - - /// 色卡那一级。 - pub fn preset(&self) -> Outcome { - self.outcome(Level::Preset) - } - - /// 这次配色是否发生过回落(用了 `level` 以下的层级)。 - pub fn did_fall_back(&self) -> bool { - (0..self.level.index()).any(|i| !matches!(self.outcomes[i], Outcome::Absent)) - } - - /// 第一个「有输入但不合格」的层级(提示文案优先说它)。 - pub fn first_failure(&self) -> Option<(Level, Skip)> { - const LEVELS: [Level; 3] = [Level::BackgroundImage, Level::Wallpaper, Level::Preset]; - LEVELS - .into_iter() - .find_map(|level| match self.outcome(level) { - Outcome::Failed(skip) => Some((level, skip)), - _ => None, - }) - } - - /// UI 提示键(`None` = 无需提示)。 - /// - /// 形如 `palette.hint.background.low-chroma`,文案映射表见 - /// `DevDocs/SubAgentsTalk/palette-source-contract.md`。`key` 稳定、可持久化进日志; - /// 本 crate 不产出人话文案(文案属于 UI 层,D40 的分层纪律)。 - /// - /// 优先级:① 先解释「为什么回落」(第一个失败的层级)→ ② 再说「微调过对比度」。 - /// 前者对用户更重要:用户看到的是「我的壁纸怎么没生效」,而不是「颜色被调了 2%」。 - pub fn hint_key(&self) -> Option<&'static str> { - if let Some((level, skip)) = self.first_failure() { - return Some(match (level, skip) { - (Level::BackgroundImage, Skip::Undecodable) => { - "palette.hint.background.undecodable" - } - (Level::BackgroundImage, Skip::TooSmall { .. }) => { - "palette.hint.background.too-small" - } - (Level::BackgroundImage, Skip::BadDimensions { .. }) => { - "palette.hint.background.bad-dimensions" - } - (Level::BackgroundImage, Skip::NoOpaquePixels) => { - "palette.hint.background.no-opaque-pixels" - } - (Level::BackgroundImage, Skip::LowChroma { .. }) => { - "palette.hint.background.low-chroma" - } - (Level::BackgroundImage, Skip::Contrast { .. }) => { - "palette.hint.background.contrast" - } - (Level::Wallpaper, Skip::Undecodable) => "palette.hint.wallpaper.undecodable", - (Level::Wallpaper, Skip::TooSmall { .. }) => "palette.hint.wallpaper.too-small", - (Level::Wallpaper, Skip::BadDimensions { .. }) => { - "palette.hint.wallpaper.bad-dimensions" - } - (Level::Wallpaper, Skip::NoOpaquePixels) => { - "palette.hint.wallpaper.no-opaque-pixels" - } - (Level::Wallpaper, Skip::LowChroma { .. }) => "palette.hint.wallpaper.low-chroma", - (Level::Wallpaper, Skip::Contrast { .. }) => "palette.hint.wallpaper.contrast", - (Level::Preset, Skip::Contrast { .. }) => "palette.hint.preset.contrast", - // 色卡档不存在「解码/彩度」问题;落到这里说明是将来新增的原因,别乱编键。 - (Level::Preset, _) => "palette.hint.preset.unavailable", - }); - } - if !self.adjustments.is_empty() { - return Some("palette.hint.adjusted"); - } - None - } - - /// 原因码列表(`[级别码, 原因码]`),写日志/上报用;给不了键的细节都在这里。 - pub fn reason_codes(&self) -> Vec<(&'static str, &'static str)> { - const LEVELS: [Level; 3] = [Level::BackgroundImage, Level::Wallpaper, Level::Preset]; - LEVELS - .into_iter() - .filter_map(|level| match self.outcome(level) { - Outcome::Failed(skip) => Some((level.code(), skip.code())), - _ => None, - }) - .collect() - } -} - /// 三级来源编排(纯函数)。 /// /// 参数: @@ -409,6 +198,7 @@ fn card_key(card: Card) -> CardKey { #[cfg(test)] mod tests { use super::*; + use crate::color::Color; fn solid(color: Color, width: u32, height: u32) -> Vec { let mut out = Vec::with_capacity((width * height * 4) as usize); diff --git a/crates/design/src/palette/resolve/provenance.rs b/crates/design/src/palette/resolve/provenance.rs new file mode 100644 index 0000000..ee8c63b --- /dev/null +++ b/crates/design/src/palette/resolve/provenance.rs @@ -0,0 +1,237 @@ +//! 一次配色的「结果解释」:最终用了哪一级、前几级为什么没用、对比度被改了什么。 +//! +//! 这些类型是**给 UI 看的契约**([`Provenance::hint_key`] 的键 → 文案的映射见 +//! `DevDocs/SubAgentsTalk/palette-source-contract.md`),所以刻意做得比内部需要更细: +//! 每一种失败都带足以写出一句具体提示的数据(彩度是多少、图有多小、哪一级坏了)。 +//! +//! 与 [`super`] 拆开只为单文件 ≤400 行;对外路径仍是 `palette::resolve::*`。 + +use super::super::card::CardKey; +use super::super::seed::{Failure, Seed}; +use super::super::wcag::Adjustment; +use crate::color::{Color, SchemeFlavor}; + +/// 方案的来源层级(D38 的三级)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Level { + /// ① 启动器背景图取色。 + BackgroundImage, + /// ② 桌面壁纸取色。 + Wallpaper, + /// ③ 内置色卡。 + Preset, +} + +impl Level { + /// 稳定码(日志/设置里用,别用 `Debug` 输出)。 + pub const fn code(self) -> &'static str { + match self { + Level::BackgroundImage => "background-image", + Level::Wallpaper => "wallpaper", + Level::Preset => "preset", + } + } + + /// 在本模块内部当索引用(0 = ①)。 + /// + /// `pub(super)`:`resolve` 的编排函数要按下标填 `Provenance` 的三个格子, + /// 但它不该出现在对外 API 里(外部只该问「哪一级」,不该拿索引当协议)。 + pub(super) const fn index(self) -> usize { + match self { + Level::BackgroundImage => 0, + Level::Wallpaper => 1, + Level::Preset => 2, + } + } +} + +/// 某一级的处置结果。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Outcome { + /// 用了这一级。 + Used, + /// 用户没提供这一级(没设背景图 / 没开莫奈取色)。 + Absent, + /// 有输入但不合格,已跳过。 + Failed(Skip), +} + +/// 「有输入但不合格」的具体原因——每一条都要能变成一句人话。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Skip { + /// 解码失败(原因在适配层,这里只知道失败)。 + Undecodable, + /// 图太小(< 4 像素)。 + TooSmall { + /// 宽。 + width: u32, + /// 高。 + height: u32, + }, + /// 像素缓冲长度与宽高不符(调用方给错了数据)。 + BadDimensions { + /// 期望字节数。 + expected: usize, + /// 实际字节数。 + actual: usize, + }, + /// 全是透明像素。 + NoOpaquePixels, + /// 彩度不足(D40:`< MIN_SEED_CHROMA`),硬取色会凭空上色。 + LowChroma { + /// 实测彩度。 + chroma: f64, + /// 最接近的候选色(提示文案里显示成色块)。 + color: Color, + }, + /// 取色成功但对比度收敛后仍不达标。 + Contrast { + /// 仍不达标的对数。 + failures: usize, + }, +} + +impl Skip { + /// 稳定原因码(`Provenance::hint_key` 的后半段)。 + pub const fn code(self) -> &'static str { + match self { + Skip::Undecodable => "undecodable", + Skip::TooSmall { .. } => "too-small", + Skip::BadDimensions { .. } => "bad-dimensions", + Skip::NoOpaquePixels => "no-opaque-pixels", + Skip::LowChroma { .. } => "low-chroma", + Skip::Contrast { .. } => "contrast", + } + } +} + +impl From for Skip { + fn from(failure: Failure) -> Self { + match failure { + Failure::BadDimensions { + expected, actual, .. + } => Skip::BadDimensions { expected, actual }, + Failure::TooSmall { width, height } => Skip::TooSmall { width, height }, + Failure::NoOpaquePixels => Skip::NoOpaquePixels, + Failure::LowChroma { color, chroma } => Skip::LowChroma { color, chroma }, + } + } +} + +/// 这次配色「最后用了什么、前几级为什么没用」。 +#[derive(Debug, Clone, PartialEq)] +pub struct Provenance { + /// 最终生效的层级。 + pub level: Level, + /// 当前生效的色卡(取色成功时它仍然参与:中性彩度来自它)。 + pub card: CardKey, + /// 取色成功时的种子(色卡档为 `None`)。 + pub seed: Option, + /// 深浅模式。 + pub flavor: SchemeFlavor, + /// 对比度收敛改了哪些角色(空 = 没动)。 + pub adjustments: Vec, + /// 三级各自的处置结果(`pub(super)`:只能由 `resolve::resolve_source` 填, + /// 外部一律通过 [`Provenance::outcome`] / [`Provenance::background`] 等访问器读)。 + pub(super) outcomes: [Outcome; 3], +} + +impl Provenance { + /// 某一级发生了什么。 + pub fn outcome(&self, level: Level) -> Outcome { + self.outcomes[level.index()] + } + + /// 背景图那一级。 + pub fn background(&self) -> Outcome { + self.outcome(Level::BackgroundImage) + } + + /// 壁纸那一级。 + pub fn wallpaper(&self) -> Outcome { + self.outcome(Level::Wallpaper) + } + + /// 色卡那一级。 + pub fn preset(&self) -> Outcome { + self.outcome(Level::Preset) + } + + /// 这次配色是否发生过回落(用了 `level` 以下的层级)。 + pub fn did_fall_back(&self) -> bool { + (0..self.level.index()).any(|i| !matches!(self.outcomes[i], Outcome::Absent)) + } + + /// 第一个「有输入但不合格」的层级(提示文案优先说它)。 + pub fn first_failure(&self) -> Option<(Level, Skip)> { + const LEVELS: [Level; 3] = [Level::BackgroundImage, Level::Wallpaper, Level::Preset]; + LEVELS + .into_iter() + .find_map(|level| match self.outcome(level) { + Outcome::Failed(skip) => Some((level, skip)), + _ => None, + }) + } + + /// UI 提示键(`None` = 无需提示)。 + /// + /// 形如 `palette.hint.background.low-chroma`,文案映射表见 + /// `DevDocs/SubAgentsTalk/palette-source-contract.md`。`key` 稳定、可持久化进日志; + /// 本 crate 不产出人话文案(文案属于 UI 层,D40 的分层纪律)。 + /// + /// 优先级:① 先解释「为什么回落」(第一个失败的层级)→ ② 再说「微调过对比度」。 + /// 前者对用户更重要:用户看到的是「我的壁纸怎么没生效」,而不是「颜色被调了 2%」。 + pub fn hint_key(&self) -> Option<&'static str> { + if let Some((level, skip)) = self.first_failure() { + return Some(match (level, skip) { + (Level::BackgroundImage, Skip::Undecodable) => { + "palette.hint.background.undecodable" + } + (Level::BackgroundImage, Skip::TooSmall { .. }) => { + "palette.hint.background.too-small" + } + (Level::BackgroundImage, Skip::BadDimensions { .. }) => { + "palette.hint.background.bad-dimensions" + } + (Level::BackgroundImage, Skip::NoOpaquePixels) => { + "palette.hint.background.no-opaque-pixels" + } + (Level::BackgroundImage, Skip::LowChroma { .. }) => { + "palette.hint.background.low-chroma" + } + (Level::BackgroundImage, Skip::Contrast { .. }) => { + "palette.hint.background.contrast" + } + (Level::Wallpaper, Skip::Undecodable) => "palette.hint.wallpaper.undecodable", + (Level::Wallpaper, Skip::TooSmall { .. }) => "palette.hint.wallpaper.too-small", + (Level::Wallpaper, Skip::BadDimensions { .. }) => { + "palette.hint.wallpaper.bad-dimensions" + } + (Level::Wallpaper, Skip::NoOpaquePixels) => { + "palette.hint.wallpaper.no-opaque-pixels" + } + (Level::Wallpaper, Skip::LowChroma { .. }) => "palette.hint.wallpaper.low-chroma", + (Level::Wallpaper, Skip::Contrast { .. }) => "palette.hint.wallpaper.contrast", + (Level::Preset, Skip::Contrast { .. }) => "palette.hint.preset.contrast", + // 色卡档不存在「解码/彩度」问题;落到这里说明是将来新增的原因,别乱编键。 + (Level::Preset, _) => "palette.hint.preset.unavailable", + }); + } + if !self.adjustments.is_empty() { + return Some("palette.hint.adjusted"); + } + None + } + + /// 原因码列表(`[级别码, 原因码]`),写日志/上报用;给不了键的细节都在这里。 + pub fn reason_codes(&self) -> Vec<(&'static str, &'static str)> { + const LEVELS: [Level; 3] = [Level::BackgroundImage, Level::Wallpaper, Level::Preset]; + LEVELS + .into_iter() + .filter_map(|level| match self.outcome(level) { + Outcome::Failed(skip) => Some((level.code(), skip.code())), + _ => None, + }) + .collect() + } +}