Files
Unlucky-Launcher/crates/design/src/palette/resolve/mod.rs
T
AskaEth de9d5337ac feat(design): 内置色卡改用固定中性色相,切卡只换强调色(D43)
色卡路径原先走 TonalSpot「中性色也跟种子染色」,八张卡的中性色相 = 种子色相,
浅色档 surface 的实测 CAM16 彩度从 1.29(rose)跨到 12.03(teal)——tone 98 处
暖相色域装不下彩度 12,同一个请求值在暖相上只兑现 1.29。9 倍的底色跳变让用户在
设置页逐张试色时觉得「每张卡都像换了个应用」。

- `color::palettes::NEUTRAL_HUE_DEG`(新常量)= 出厂默认种子 `#3C8DBC` 的 CAM16
  色相 239.51420577630955°,出处与「换默认色时要复核什么」写在常量文档里;
  `neutral_hue_is_the_default_seed_hue` 用位相等钉住它与 `DEFAULT_SEED` 的绑定。
- `Palettes::accent_on_fixed_neutral(accent, neutral_hue, neutral_chroma)`:强调三族
  跟 accent 色相,中性两族固定在给定色相;其余常数(36/16/24/25°/84、tone 表、
  gamut 映射)一行未动。私有 `tonal_spot_with_neutral` 承载实现,`tonal_spot` 只是
  它的「中性色相 = 种子色相」特例。
- `Card::scheme` 改走上述入口(`NEUTRAL_HUE_DEG` + 卡自己的中性彩度)。默认档天蓝的
  49 个角色**逐值不变**(固定在等于它自己中性色相的那个值上,属「沿用」而非「另选」)。
- 动态取色路径**刻意不动**:`resolve::try_level` 仍按图像种子逐色染色、只借色卡的中性
  彩度定浓淡;两处注释都写明「两条路径不同,别统一处理」。
- 订正 `palette/card.rs` 的模块文档:原文把「请求值相同」当成「观感相同」,现在改成
  可直接当契约读的三条(表面与描边八卡逐值相同 / 只有强调族跟卡走 / 天蓝卡 == baseline)。

门禁:fmt 干净、clippy -D warnings 通过、`cargo test -p unlucky-design` 全绿、
`cargo tree -p unlucky-design --edges normal` 仍是单行(零第三方依赖)。
2026-11-02 13:12:41 +08:00

204 lines
7.9 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.
//! 三级来源编排:**纯函数**,一次调用算完「用哪一级的颜色、为什么不是别的级」。
//!
//! D38 的优先序:① 启动器背景图 → ② 桌面壁纸 → ③ 内置色卡。
//! 本模块不做任何 IO:像素是调用方解码好传进来的(D40 分层),壁纸路径探测是 M73 的事。
//!
//! ```no_run
//! use unlucky_design::palette::{resolve_source, CardKey, ImageInput, ImageSource, Level};
//! use unlucky_design::color::SchemeFlavor;
//!
//! let pixels = [0x3C, 0x8D, 0xBC, 0xFF].repeat(16); // 4×4 天蓝图(示例)
//! let bg = ImageInput::Pixels(ImageSource { pixels: &pixels, width: 4, height: 4 });
//! let (scheme, provenance) = resolve_source(Some(bg), None, Some(CardKey::Forest), SchemeFlavor::Light);
//! assert_eq!(provenance.level, Level::BackgroundImage);
//! assert!(provenance.hint_key().is_none(), "一级成功时没有提示");
//! ```
//!
//! ## 失败链:每一级都可能「有输入但不合格」
//!
//! | 情形 | 结果 | `Provenance` 里留下 |
//! |---|---|---|
//! | 没有背景图 | 跳到 ② | `Outcome::Absent` |
//! | 更高级已经成功 | 不再看这一级 | `Outcome::NotReached`(**不等于**「用户没提供」) |
//! | 背景图解码失败 | 跳到 ② | `Outcome::Failed(Skip::Undecodable)` |
//! | 背景图太小 / 像素缓冲不对 / 全透明 | 跳到 ② | `… TooSmall / BadDimensions / NoOpaquePixels` |
//! | 背景图彩度不足(D40 的 `< 8`) | 跳到 ② | `… LowChroma { chroma }` |
//! | 背景图取色成功但对比度收敛失败 | 跳到 ② | `… Contrast { failures }`(理论上不会发生,仍要留) |
//! | 三级都不成立 | 用色卡,**一定**有结果 | `Level::Preset` |
//!
//! 三条纪律:
//!
//! 1. **不许静默**。任何一次回落都在 `Provenance` 里留下原因码,[`Provenance::hint_key`]
//! 给 UI 一个稳定的提示键(文案映射见 `DevDocs/SubAgentsTalk/palette-source-contract.md`)。
//! 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, Seed};
use super::wcag::{check, converge, Adjustment};
use crate::color::{Scheme, SchemeFlavor};
mod provenance;
pub use provenance::{Level, Outcome, Provenance, Skip};
/// 已解码的图片:借用像素,不拷贝(解码在 `crates/palette-extract`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageSource<'a> {
/// RGBA8 像素,`width × height × 4` 字节。
pub pixels: &'a [u8],
/// 宽(像素)。
pub width: u32,
/// 高(像素)。
pub height: u32,
}
/// 某一级来源的输入。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageInput<'a> {
/// 解码好的像素。
Pixels(ImageSource<'a>),
/// 有这张图,但解码失败(文件损坏 / 格式不支持 / 读不到)。
///
/// 这个状态由适配层传进来——不传就等于「没有这张图」,UI 会说错话。
Undecodable,
}
impl<'a> ImageInput<'a> {
/// 便捷构造:从像素缓冲包一个输入。
pub fn pixels(pixels: &'a [u8], width: u32, height: u32) -> Self {
ImageInput::Pixels(ImageSource {
pixels,
width,
height,
})
}
}
/// 三级来源编排(纯函数)。
///
/// 参数:
/// - `background`:用户设的启动器背景图(`None` = 没设)。解码失败传
/// [`ImageInput::Undecodable`],**不要**传 `None`(那样 UI 分不清「没设」和「坏了」)。
/// - `wallpaper`:桌面壁纸(`None` = 没开莫奈 / 探测不到壁纸文件)。
/// - `preset`:用户当前选中的色卡(`None` = 从没选过 → 用 [`super::DEFAULT_CARD`])。
/// - `flavor`:深浅模式。
///
/// 返回值永远有一套可用方案:三级全失败时落到色卡,并带上原因。
pub fn resolve_source(
background: Option<ImageInput<'_>>,
wallpaper: Option<ImageInput<'_>>,
preset: Option<CardKey>,
flavor: SchemeFlavor,
) -> (Scheme, Provenance) {
let card = preset.map(Card::from).unwrap_or(DEFAULT_CARD);
// 先假定「没轮到」,逐级评估时再改成 Absent / Failed / Used。
let mut outcomes = [Outcome::NotReached; 3];
for (level, input) in [
(Level::BackgroundImage, background),
(Level::Wallpaper, wallpaper),
] {
match try_level(input, card, flavor) {
Attempt::Absent => outcomes[level.index()] = Outcome::Absent,
Attempt::Failed(skip) => outcomes[level.index()] = Outcome::Failed(skip),
Attempt::Used {
scheme,
seed,
adjustments,
} => {
outcomes[level.index()] = Outcome::Used;
return (
*scheme,
Provenance {
level,
card: card_key(card),
seed: Some(seed),
flavor,
adjustments,
outcomes,
},
);
}
}
}
// 第三级:色卡。收敛仍然要跑(色卡也可能因为将来改常数而越线),但落点是它自己。
let (scheme, adjustments) = converge(card.scheme(flavor));
let report = check(&scheme);
outcomes[Level::Preset.index()] = if report.is_clean() {
Outcome::Used
} else {
Outcome::Failed(Skip::Contrast {
failures: report.failures.len(),
})
};
(
scheme,
Provenance {
level: Level::Preset,
card: card_key(card),
seed: None,
flavor,
adjustments,
outcomes,
},
)
}
/// 单级的处理结果。
///
/// `scheme` 装箱只是为了枚举大小(`Scheme` 是 49 个色值 ≈ 200 字节,逐级在栈上传会白白拷两次);
/// 这条路径每次配色只走一次,堆分配的开销可忽略。
enum Attempt {
Absent,
Failed(Skip),
Used {
scheme: Box<Scheme>,
seed: Seed,
adjustments: Vec<Adjustment>,
},
}
fn try_level(input: Option<ImageInput<'_>>, card: Card, flavor: SchemeFlavor) -> Attempt {
let source = match input {
None => return Attempt::Absent,
Some(ImageInput::Undecodable) => return Attempt::Failed(Skip::Undecodable),
Some(ImageInput::Pixels(source)) => source,
};
let seed = match analyze_rgba(source.pixels, source.width, source.height) {
Ok(seed) => seed,
Err(failure) => return Attempt::Failed(Skip::from(failure)),
};
// 取色方案的表面色**跟着图像种子染色**,只借当前色卡的中性彩度定浓淡(D43):
// 这是与色卡路径刻意不同的一处——色卡固定中性色相、只换强调色,取色保留整幅染色
// (那是用户要的 Monet 体验)。别把两条路径「统一处理」,`tests/card_neutral.rs` 有反证。
let (scheme, adjustments) = converge(seed.scheme(flavor, card.neutral_chroma));
let report = check(&scheme);
if !report.is_clean() {
return Attempt::Failed(Skip::Contrast {
failures: report.failures.len(),
});
}
Attempt::Used {
scheme: Box::new(scheme),
seed,
adjustments,
}
}
fn card_key(card: Card) -> CardKey {
CardKey::from_key(card.key).unwrap_or(CardKey::Sky)
}
#[cfg(test)]
mod tests;