feat(app-iced): M93 r2 —— 背景图真图直出 + 面板 α 收敛到 Ui::layer

复审前补齐两处(tower 扩 scope:Cargo.toml / pages/frame.* / theme.rs / presenter::prefs)。

1) 背景图**真图直出**
- `iced` 开 `image-without-codecs`(刻意不用 `image`:后者会打开 `image/default` 全套编解码器);
  编解码能力由本 crate 的 `image` features 决定,补了 `jpeg` / `bmp`(用户挑的背景图/壁纸多半是 jpg)。
- `pages/appearance/picture.rs`:`Cover`/`Contain` 走渲染器 `ContentFit`,`Tile` 按 `background::tiles`
  算行列(上限 64 块)真的铺 N 个图块;暗化 = 一层「版面底色 @ α=dim」叠在图上。
- 36dp 马赛克那条路**已删除**(不再是主路径,也不留死代码)。
- 踩坑记录:`Handle::from_bytes` 的纹理分配赶不上截图第 3 帧(实测画面全空),改用
  `Handle::from_path`(渲染器自己解码),已在 `picture::image_handle` 的文档里写明。

2) 面板 α **收敛到唯一来源**
- `Ui::layer()` 直接乘上面板 α(新增 `Ui::panel_alpha` / `with_panel_alpha`,默认 1.0、
  `scale_alpha(1.0)` 逐字节不变),`app::rebuild_ui` 注入生效值。
- 于是外壳(根容器/标题栏/导航轨)、`frame::card`(实例/下载/日志三页卡片)、
  `frame::header_bar`(新增底色)、外观分组卡片、空态图标块**全部同源**,
  调用点不再有任何 `panel_color(..)` 自乘(该 helper 已删)。
- 有背景图时根容器不再盖一层实色画布(否则 panel α=1 时背景图整块被挡)。

3) `presenter::prefs` 补重导出 `PANEL_ALPHA_MIN` / `BG_DIM_MAX`,app-iced 里两份 0.4 / 0.95
   字面量改为转发常量。

验收:11+3 张图(`C:\Temp\ull-shots\wt-93\r2-*.png`)——三档填充肉眼可分、`.jpg` 能加载、
GPU 档 panel55 时卡片/分组卡/页头带都透出背景、CPU 档仍强制 1.0 且背景在面板空隙可见;
另附实例/下载/日志三页 panel55 各一张,证明三页都吃到同一个 α。
This commit is contained in:
AskaEth committed 2026-11-02 18:07:32 +08:00
1 parent b8ab46662c
commit 22e4c423fa
18 files changed
+399 -535

No files matched your search

+50 -43
View File
@@ -1,23 +1,18 @@
//! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。
//!
//! ## 边界(M61 `design-令牌约定.md` §0 的落地)
//! ## 边界(M61 `design-令牌约定.md` §0)
//!
//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方。组件层(`shell`)
//! 只调 [`Ui`] 的方法,绝不出现色值 / 字号 / 半径 / 间距字面量——`tests/no_hardcoded_tokens.rs`
//! 会扫 `src/**` 做守门。
//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方:组件层只调 [`Ui`],
//! 绝不出现色值/字号/半径/间距字面量(`tests/no_hardcoded_tokens.rs` 扫 `src/**` 守门)。
//!
//! ## 为什么不用 `iced::theme::Base` 自定义主题承载 49 个角色
//! ## 为什么不用 `iced::theme::Base` 承载 49 个角色
//!
//! `iced` 的 `Palette` 只有 6 个颜色(`background/text/primary/success/warning/danger`),
//! M3 有 49 个角色,塞不进去(M60 Q5 + M61 §9-0 的同一结论)。所以:
//! iced 的 `Palette` 只有 6 个颜色,M3 有 49 个角色(M60 Q5 + M61 §9-0)。所以真源是本模块持有
//! 的 [`Scheme`],iced 的 `Theme` 只用来「换名字」——名字变了 iced 才重算受主题影响的缓存,
//! `palette()` 只给 devtools 看,控件外观由各自的 `Style` 注入。
//!
//! - **真源**是本模块持有的 [`Scheme`](49 角色);
//! - iced 的 `Theme` 只用来「换名字」——`Theme::name()` 变了 iced 才会重算受主题影响的缓存
//! (M61 §9 的最后一条)。`palette()` 只给 devtools 看,控件外观一律由各自的 `Style` 注入。
//!
//! `success` / `warning` 在 M3 里**没有对应角色**(`design-令牌约定.md` §2 明写),
//! 这里按「语义最近的现成角色」映射:`success → Secondary`(不是「成功」的承诺)、
//! `warning → Tertiary`(M3 把「提醒」放 Tertiary)。任何控件都不要去读这两个槽位。
//! `success`/`warning` 在 M3 里没有对应角色,按语义最近的现成角色映射
//! (`success → Secondary`、`warning → Tertiary`),任何控件都不要读这两个槽位。
use iced::border::Radius;
use iced::theme::Palette;
@@ -29,10 +24,7 @@ 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`:全仓只有令牌层一个定义点。
/// 全站描边宽度(rubric G7:1px)。令牌层的 `stroke::HAIRLINE` 是唯一定义点,这里只再导出。
pub use unlucky_design::stroke::HAIRLINE as STROKE_PX;
/// 已经解析到系统的字体族名(`&'static str` 才能塞进 `iced::Font`)。
@@ -48,15 +40,16 @@ pub struct Families {
pub mono: &'static str,
}
/// 令牌求值器:一个 `Ui` 对应一套主题(浅或深)。
///
/// 深浅热切 = 重新 `Ui::new(flavor, families)` 并整树重绘;控件里不要缓存颜色。
/// 令牌求值器:一个 `Ui` 对应一套主题(浅或深)。热切 = 重建 `Ui` 并整树重绘,
/// 控件里不要缓存颜色。
#[derive(Debug, Clone)]
pub struct Ui {
scheme: Scheme,
platform: Platform,
metrics: LayoutMetrics,
families: Families,
/// 面板层不透明度(D36 / M93):**唯一的 α 来源**,由 [`Ui::layer`] 统一施加。
panel_alpha: f32,
}
impl Ui {
@@ -72,27 +65,26 @@ impl Ui {
platform,
metrics: LayoutMetrics::DESKTOP,
families,
panel_alpha: 1.0,
}
}
/// 当前深浅模式。
/// 当前深浅。
pub fn flavor(&self) -> SchemeFlavor {
self.scheme.flavor()
}
/// 用**已解析的方案**构造求值器(D38 的落点:背景图 / 桌面壁纸取色 → 49 个角色换掉 baseline)。
/// 用**已解析的方案**构造求值器(D38:背景图 / 壁纸取色 → 49 个角色换掉 baseline)。
///
/// 只是**追加**的构造入口:既有的 [`Ui::new`] / [`Ui::with_platform`] 一字未动,默认仍是
/// [`Scheme::baseline`]。调用方(`app` 侧)拿到 `unlucky_design::palette::resolve_source` 的
/// 结果后走这里,于是「配色来源」真的能改颜色,而不只是一条日志。
///
/// 平台与骨架尺寸仍按当前环境求值——方案只带颜色,不带平台。
/// 追加式入口:`new` / `with_platform` 的行为一字未动(默认仍是 [`Scheme::baseline`]);
/// 平台与骨架尺寸仍按当前环境求值——方案只带颜色。
pub fn from_scheme(scheme: Scheme, families: Families) -> Self {
Self {
scheme,
platform: Platform::current(),
metrics: LayoutMetrics::DESKTOP,
families,
panel_alpha: 1.0,
}
}
@@ -121,25 +113,44 @@ impl Ui {
to_iced(self.scheme.get(role))
}
/// 材质层(Canvas/Card/Overlay)→ 容器色。
/// 材质层(Canvas/Card/Overlay)→ 容器色,**已乘上面板 α**(M93 的唯一来源)。
///
/// 为什么 α 施加在这里:面板 α 是**表面色**的属性,而全仓所有表面色都从这个函数出去
/// (外壳的根容器/标题栏/导航轨、`pages::frame::card`、空态图标块……),乘一次就让
/// 四页 + 外壳自动同源,调用点没有机会「各处自乘」。`α = 1.0` 时逐字节等于原色。
pub fn layer(&self, layer: MaterialLayer) -> Color {
self.color(layer.surface_role())
.scale_alpha(self.panel_alpha)
}
/// 状态层叠在容器色上,返回**不透明**结果。
///
/// 为什么不用带 alpha 的叠加:D27 要求 CPU 档不出现半透明,而「不透明结果」让 GPU/CPU
/// 两档的颜色也逐像素一致(只有材质/玻璃才允许两档不同)。
/// 当前面板 α(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` 是控件在该轴上的边长(`ShapeScale::Full` 要按边长折算)。
/// 圆角(`side` = 控件在该轴上的边长,`Full` 要按它折算)。
pub fn radius(&self, shape: ShapeScale, side: f32) -> Radius {
Radius::new(shape.radius_px(side))
}
/// 固定档圆角(不需要边长)。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。
/// 固定档圆角。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。
pub fn radius_dp(&self, shape: ShapeScale) -> Radius {
debug_assert!(
!matches!(shape, ShapeScale::Full),
@@ -171,7 +182,7 @@ impl Ui {
role.emphasized()
}
/// 某个排版档对应的 iced 字体(族 + 字重)。
/// 排版档 → iced 字体(族 + 字重)。
pub fn font_of(&self, style: TypeStyle) -> Font {
let family = match style.family {
FontFamily::Display => self.families.display,
@@ -196,7 +207,7 @@ impl Ui {
self.font_of(self.emphasized(role))
}
/// iced 的 6 色 `Palette`(只给 devtools / 兜底控件用;M3 的 49 角色在 [`Ui::color`])。
/// iced 的 6 色 `Palette`(只给 devtools;M3 的 49 角色在 [`Ui::color`])。
pub fn palette(&self) -> Palette {
Palette {
background: self.color(Role::Surface),
@@ -348,12 +359,8 @@ mod tests {
assert_ne!(hovered, ui.color(Role::Surface));
}
/// D38:配色来源解析出来的方案必须**真的换色**(M93)。
///
/// 抓得住:①`from_scheme` 用的是传进来的方案,不是 baseline(配一张蓝底的取色方案,
/// primary 与 baseline 不同);②默认构造(`new` / `with_platform`)逐字段等于 baseline
/// ——即「不传 scheme 时行为不变」,这条是防回流的那一半。
/// 抓不住:取色结果好不好看、设置页是否真的切过去(前者是设计判断,后者由 app 侧日志举证)。
/// D38:配色方案必须真的换色;默认构造逐字段等于 baseline(防回流的那一半)。
/// 抓不住:取色好不好看、设置页是否真切过去(后者由 app 侧日志举证)。
#[test]
fn a_resolved_scheme_replaces_the_baseline_colours() {
use unlucky_design::palette::{resolve_source, CardKey, ImageInput};