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:
1 parent
b8ab46662c
commit
22e4c423fa
18 files changed
+399
-535
No files matched your search
Generated
+18
@@ -2751,6 +2751,7 @@ dependencies = [
|
||||
"iced_runtime",
|
||||
"iced_widget",
|
||||
"iced_winit",
|
||||
"image",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -2809,6 +2810,8 @@ dependencies = [
|
||||
"half",
|
||||
"iced_core",
|
||||
"iced_futures",
|
||||
"image",
|
||||
"kamadak-exif",
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rustc-hash 2.1.3",
|
||||
@@ -3316,6 +3319,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kamadak-exif"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"
|
||||
dependencies = [
|
||||
"mutate_once",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.7.0"
|
||||
@@ -3757,6 +3769,12 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mutate_once"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "27.0.3"
|
||||
|
||||
@@ -37,12 +37,21 @@ iced = { version = "=0.14.0", default-features = false, features = [
|
||||
"tiny-skia",
|
||||
"crisp",
|
||||
"advanced",
|
||||
# M93:背景图要真图直出 ⇒ 需要渲染器的 image 支持。用 `image-without-codecs` 而不是
|
||||
# `image` 是刻意的:后者会打开 `image/default` 的全套编解码器;前者的编解码能力由本 crate
|
||||
# 依赖表里那条 `image` 的 features 决定(png / jpeg / bmp),不必把 webp/tiff/exr/gif 拖进来。
|
||||
"image-without-codecs",
|
||||
"svg",
|
||||
"thread-pool",
|
||||
] }
|
||||
|
||||
# 截图落 PNG。只开 png 编码器,不要默认全套 codec(体积)。
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# 截图落 PNG + 背景图解码(M93):png 是截图通道,jpeg/bmp 是用户真会挑的背景图/壁纸格式
|
||||
# (M73 的壁纸探测不筛扩展名)。仍不开 default-features——那会拖进 avif/ravif/webp/tiff 一整套。
|
||||
image = { version = "0.25", default-features = false, features = [
|
||||
"png",
|
||||
"jpeg",
|
||||
"bmp",
|
||||
] }
|
||||
|
||||
# M82:同一个二进制也带 CLI 子命令(`unlucky-launcher-iced instances list` …),
|
||||
# 进 UI 之前分发,命中就把控制权交给 `unlucky_cli::run`。依赖方向 app-iced → cli →
|
||||
|
||||
@@ -235,6 +235,9 @@ impl App {
|
||||
/// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的
|
||||
/// 像素**改掉。这条偏离登记在 `pages::appearance` 的偏离表 #4。
|
||||
pub(super) fn rebuild_ui(&mut self) {
|
||||
// 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一**
|
||||
// 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。
|
||||
let panel_alpha = self.appearance.effective_panel_alpha();
|
||||
self.ui = match self.appearance.derived_scheme() {
|
||||
Some(scheme) => {
|
||||
eprintln!(
|
||||
@@ -244,7 +247,8 @@ impl App {
|
||||
Ui::from_scheme(*scheme, self.families)
|
||||
}
|
||||
None => Ui::new(self.appearance.flavor(), self.families),
|
||||
};
|
||||
}
|
||||
.with_panel_alpha(panel_alpha);
|
||||
}
|
||||
|
||||
/// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。
|
||||
|
||||
@@ -130,7 +130,7 @@ fn a_broken_background_path_falls_back_silently_but_says_why() {
|
||||
.apply(Change::BgImage("D:/definitely/not/here.png".to_owned()));
|
||||
|
||||
assert!(!app.appearance.background_active());
|
||||
assert!(app.appearance.mosaic((1180.0, 760.0), [0, 0, 0]).is_none());
|
||||
assert!(app.appearance.image().is_none(), "解码失败 ⇒ 没有可用图");
|
||||
assert_eq!(app.appearance.diagnostics().len(), 1, "恰好一条诊断");
|
||||
assert!(
|
||||
app.appearance.diagnostics()[0].contains("could not be read"),
|
||||
@@ -169,11 +169,11 @@ fn a_decodable_png_makes_the_background_and_the_palette_active() {
|
||||
app.rebuild_ui();
|
||||
|
||||
assert!(app.appearance.background_active());
|
||||
let mosaic = app
|
||||
.appearance
|
||||
.mosaic((1180.0, 760.0), [0, 0, 0])
|
||||
.expect("有背景层");
|
||||
assert!(mosaic.cols > 1 && mosaic.rows > 1);
|
||||
assert!(app.appearance.image().is_some(), "解码成功 ⇒ 有可用图");
|
||||
assert!(
|
||||
!app.appearance.bg_path.trim().is_empty(),
|
||||
"渲染器按路径解码(见 picture::image_handle)"
|
||||
);
|
||||
assert_eq!(app.appearance.diagnostics().len(), 0, "成功时没有诊断");
|
||||
assert_eq!(app.appearance.palette_level(), "background-image");
|
||||
assert!(
|
||||
@@ -286,5 +286,5 @@ fn a_fresh_state_is_the_factory_appearance() {
|
||||
assert_eq!(state.effective_panel_alpha(), 1.0);
|
||||
assert!(!state.background_active());
|
||||
assert!(state.diagnostics().is_empty());
|
||||
assert!(state.mosaic((1180.0, 760.0), [0, 0, 0]).is_none());
|
||||
assert!(state.image().is_none());
|
||||
}
|
||||
@@ -56,7 +56,6 @@ impl App {
|
||||
nav_item_of(self.state.route()),
|
||||
APP_TITLE,
|
||||
background,
|
||||
self.appearance.effective_panel_alpha(),
|
||||
pages::page_with_appearance(&self.ui, &self.view, &self.appearance)
|
||||
.map(Message::Page),
|
||||
self.options.force_state,
|
||||
|
||||
@@ -37,12 +37,8 @@ use crate::Backend;
|
||||
/// 整窗 α 下限的 `f32` 形态(唯一真源仍是 `unlucky_presenter::prefs::WINDOW_ALPHA_MIN`)。
|
||||
pub const WINDOW_ALPHA_FLOOR: f32 = WINDOW_ALPHA_MIN as f32;
|
||||
|
||||
/// 面板 α 下限(0.4)。
|
||||
///
|
||||
/// 与 `unlucky_core::setting::appearance::PANEL_ALPHA_MIN` **同值**,但只能在这里再写一遍:
|
||||
/// `unlucky-presenter` 没有重导出它(只重导出了 `WINDOW_ALPHA_MIN`),而 `app-iced`
|
||||
/// 不直接依赖 core。已报 finding,收口方式是让 presenter 重导出。
|
||||
pub const PANEL_ALPHA_FLOOR: f32 = 0.4;
|
||||
/// 面板 α 下限(core 的 `PANEL_ALPHA_MIN` = 0.4,经 `unlucky_presenter::prefs` 重导出)。
|
||||
pub const PANEL_ALPHA_FLOOR: f32 = unlucky_presenter::prefs::PANEL_ALPHA_MIN as f32;
|
||||
|
||||
/// 整窗 α 滑块位置(0..=1)→ 请求值(0.08..=1.0)。
|
||||
#[must_use]
|
||||
@@ -130,8 +126,8 @@ mod tests {
|
||||
#[test]
|
||||
fn floor_values_are_the_ones_the_user_decided() {
|
||||
// 抓得住:有人把下限改回 0(= 整窗穿透)或把面板下限降到「前景压不住背景图」。
|
||||
// 抓不住:数值本身是否来自令牌(`WINDOW_ALPHA_FLOOR` 由 presenter 重导出,这条能对;
|
||||
// 面板下限是本地字面量,只能钉 0.4 这个事实)。
|
||||
// 两个下限都来自 `unlucky_presenter::prefs` 的重导出(M93 r2 起不再有本地字面量),
|
||||
// 所以这条同时钉住「presenter 的重导出没被改坏」。
|
||||
assert_eq!(WINDOW_ALPHA_FLOOR, 0.08);
|
||||
assert_eq!(PANEL_ALPHA_FLOOR, 0.4);
|
||||
}
|
||||
|
||||
@@ -1,60 +1,51 @@
|
||||
//! 背景图:**读盘解码 → 填充几何 → 暗化 → 马赛克取样**。纯函数(除 [`load`] 一次 IO)。
|
||||
//! 背景图:**读盘解码(png / jpeg / bmp)→ 三种填充 → 暗化系数 → 可读诊断**。
|
||||
//!
|
||||
//! ## 三种填充(`appearance.bg_fill`,core 的 [`BgFill`])
|
||||
//! ## 真图直出(M93 r2 修订)
|
||||
//!
|
||||
//! | 档 | 缩放 | 位置 | 露底色 |
|
||||
//! |---|---|---|---|
|
||||
//! | `cover` | `max(vw/iw, vh/ih)`(铺满、裁掉溢出) | 居中 | 否 |
|
||||
//! | `contain` | `min(vw/iw, vh/ih)`(完整显示) | 居中 | **是**(上下或左右留底色) |
|
||||
//! | `tile` | `1`(原始尺寸平铺) | 从视口左上角起重复 | 否 |
|
||||
//! r1 因为 iced 的 `image` feature 没开,只能把像素画成 36dp 网格的马赛克。r2 打开了
|
||||
//! `iced/image`(`crates/app-iced/Cargo.toml`),于是**真图直出**:填充由 iced 的 `ContentFit`
|
||||
//! 负责(`Cover` / `Contain`),平铺由本模块的 [`tiles`] 算网格、渲染层真的铺 N 个图块。
|
||||
//! 马赛克那条路已删除(不再有「每格一个颜色」的降级)。
|
||||
//!
|
||||
//! 几何是**纯函数**([`placement`] / [`source_point`]),因此三种档的差异可以被数值断言,
|
||||
//! 不必靠肉眼比对截图。
|
||||
//! | 档 | 渲染 | 露底色 |
|
||||
//! |---|---|---|
|
||||
//! | `cover` | `ContentFit::Cover`(铺满、裁掉溢出) | 否 |
|
||||
//! | `contain` | `ContentFit::Contain`(完整显示、等比) | **是**(上下或左右) |
|
||||
//! | `tile` | 原图尺寸平铺([`tiles`] 给行列数,上限 [`MAX_TILES`]) | 否(图比视口大时=裁切) |
|
||||
//!
|
||||
//! ## 解码只在这里(一次)
|
||||
//!
|
||||
//! 一张图会被两处用到:①渲染器(`image::Handle::from_path`,由 iced 自己解码;r2 实测
|
||||
//! `from_bytes` 的异步纹理分配赶不上截图第 3 帧,画面会是空的);②取色(`unlucky_design` 要 RGBA
|
||||
//! 像素,本模块把解码结果缩到 [`MAX_EDGE`] 内交给它)。本模块的解码只为「取色 + 诊断 + 尺寸」。
|
||||
//!
|
||||
//! ## 暗化(`appearance.bg_dim`)
|
||||
//!
|
||||
//! 语义是「让背景往面板底色退」:`rgb + (toward − rgb) × dim`(`toward` = 当前主题的 Canvas 色)。
|
||||
//!
|
||||
//! 为什么**不是**「叠一层黑」:叠黑在深色主题里正合适,但在浅色主题下会把亮图压成暗块,而压在
|
||||
//! 它上面的深色正文反而更读不清。向底色退在两种主题下都是单调变好(浅色向白退、深色向黑退),
|
||||
//! 也仍然是「一次乘法式的插值」——结果**不透明**,不依赖渲染器的 alpha 混合精度,
|
||||
//! CPU 档与 GPU 档出图逐字节一致。
|
||||
//!
|
||||
//! 这条偏离(相对「暗化 = 乘黑」的字面语义)登记在 [`super`] 的偏离表 #7。
|
||||
//!
|
||||
//! ## 为什么是「马赛克」而不是直接画图
|
||||
//!
|
||||
//! iced 0.14 的 `widget::image` 需要 `iced/image` feature,而本 crate 的 iced 只开了
|
||||
//! `tiny-skia / crisp / advanced / svg / thread-pool`,`Cargo.toml` 不在本 mission 的 scope
|
||||
//! (已登记偏离 #1)。所以这里把解码后的像素按 [`CELL_PX`] 的网格做**块平均**,每格画一个实心
|
||||
//! 方块:填充几何、暗化、取色全部是真的,只有「每格一个颜色」这一条是降级。
|
||||
//! 网格上限 [`MAX_COLS`]×[`MAX_ROWS`],成本有界(1180×760 下约 33×22 个方块)。
|
||||
//! 语义是「让背景往面板底色退」,即把版面底色(`Role::Surface`)以 α = `dim` 叠在图上——
|
||||
//! 与 `rgb + (toward − rgb) × dim` **在数学上等价**(图不透明时),所以这里只给一个钳位函数
|
||||
//! [`dim_alpha`],真正的合成交给渲染器(CPU/GPU 两档都支持普通 alpha 混合)。
|
||||
//! 为什么向底色退而不是「叠黑」:浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清
|
||||
//! (偏离登记在 [`super`] 的偏离表 #7)。
|
||||
|
||||
use unlucky_presenter::prefs::BgFill;
|
||||
/// 暗化上限(core 的 `BG_DIM_MAX` = 0.95,经 `unlucky_presenter::prefs` 重导出;这里只给 f32 形态)。
|
||||
pub const BG_DIM_MAX: f32 = unlucky_presenter::prefs::BG_DIM_MAX as f32;
|
||||
|
||||
/// 暗化上限(与 core 的 `BG_DIM_MAX` 同值 0.95):再高背景图基本看不见,等于关掉。
|
||||
pub const BG_DIM_MAX: f32 = 0.95;
|
||||
|
||||
/// 解码后保留的最大边长:取样与取色都不需要分辨率,4K 图缩到 256 边后内存从 33MB 掉到 147KB。
|
||||
/// 解码后保留给取色的最大边长:取色只需要色彩分布,4K 图缩到 256 边后内存从 33MB 掉到 147KB。
|
||||
pub const MAX_EDGE: u32 = 256;
|
||||
|
||||
/// 解码**之前**允许的原始边长上限(挡住「头里写着 40000×40000」的解压炸弹)。
|
||||
pub const MAX_SOURCE_EDGE: u32 = 16_384;
|
||||
|
||||
/// 马赛克网格的目标格边长(dp)。
|
||||
pub const CELL_PX: f32 = 36.0;
|
||||
|
||||
/// 马赛克网格上限(列 / 行)——渲染成本的天花板。
|
||||
pub const MAX_COLS: u32 = 48;
|
||||
/// 马赛克网格上限(行)。
|
||||
pub const MAX_ROWS: u32 = 32;
|
||||
/// 平铺档一次最多铺多少个图块(列 × 行)。超了就不铺——避免一张 8×8 的小图在 4K 上拼出
|
||||
/// 上万个子控件。
|
||||
pub const MAX_TILES: u32 = 64;
|
||||
|
||||
/// 解码失败的原因(人话在 [`LoadError::describe`])。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LoadError {
|
||||
/// 读文件失败:不存在 / 没权限 / 被占用。
|
||||
Io(String),
|
||||
/// 解码失败:格式不认识(本 build 只带 png 编解码器)/ 文件截断 / 不是图片。
|
||||
/// 解码失败:格式不认识(webp/tiff 等没开编码器)/ 文件截断 / 不是图片。
|
||||
Undecodable(String),
|
||||
/// 图片头里的尺寸超限。
|
||||
TooLarge {
|
||||
@@ -72,7 +63,7 @@ impl LoadError {
|
||||
match self {
|
||||
Self::Io(reason) => format!("Background image could not be read: {reason}"),
|
||||
Self::Undecodable(reason) => format!(
|
||||
"Background image could not be decoded ({reason}; this build decodes PNG only)"
|
||||
"Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)"
|
||||
),
|
||||
Self::TooLarge { width, height } => format!(
|
||||
"Background image is too large to decode: {width}×{height} (limit {MAX_SOURCE_EDGE} px per edge)"
|
||||
@@ -90,12 +81,12 @@ pub struct Image {
|
||||
pub width: u32,
|
||||
/// 缩略图高(≤ [`MAX_EDGE`])。
|
||||
pub height: u32,
|
||||
/// 缩略图 RGBA8(`width × height × 4`):取样与取色都用它。
|
||||
/// 缩略图 RGBA8(取色用;`width × height × 4`)。
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// 从任意解码结果构造(测试也走这里,避免测试依赖磁盘)。
|
||||
/// 从已解码的数据构造(测试也走这里,避免测试依赖磁盘)。
|
||||
#[must_use]
|
||||
pub fn from_rgba(source: (u32, u32), width: u32, height: u32, rgba: Vec<u8>) -> Self {
|
||||
Self {
|
||||
@@ -117,8 +108,10 @@ pub fn load(path: &str) -> Result<Image, LoadError> {
|
||||
if trimmed.is_empty() {
|
||||
return Err(LoadError::Io("no path configured".to_owned()));
|
||||
}
|
||||
let bytes = std::fs::read(trimmed).map_err(|error| LoadError::Io(error.to_string()))?;
|
||||
|
||||
// 第一趟只读图头:先按尺寸拒掉压缩炸弹,再真解码。
|
||||
let (source_w, source_h) = header_size(trimmed)?;
|
||||
let (source_w, source_h) = header_size(&bytes)?;
|
||||
if source_w > MAX_SOURCE_EDGE || source_h > MAX_SOURCE_EDGE {
|
||||
return Err(LoadError::TooLarge {
|
||||
width: source_w,
|
||||
@@ -126,8 +119,7 @@ pub fn load(path: &str) -> Result<Image, LoadError> {
|
||||
});
|
||||
}
|
||||
|
||||
let decoded = open(trimmed)?
|
||||
.decode()
|
||||
let decoded = image::load_from_memory(&bytes)
|
||||
.map_err(|error| LoadError::Undecodable(error.to_string()))?;
|
||||
let (width, height) = (decoded.width().max(1), decoded.height().max(1));
|
||||
let scale = (f64::from(MAX_EDGE) / f64::from(width.max(height))).min(1.0);
|
||||
@@ -140,24 +132,19 @@ pub fn load(path: &str) -> Result<Image, LoadError> {
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
|
||||
Ok(Image::from_rgba(
|
||||
(source_w, source_h),
|
||||
target_w,
|
||||
target_h,
|
||||
thumb.into_raw(),
|
||||
))
|
||||
Ok(Image {
|
||||
source: (source_w, source_h),
|
||||
width: target_w,
|
||||
height: target_h,
|
||||
rgba: thumb.into_raw(),
|
||||
})
|
||||
}
|
||||
|
||||
fn open(path: &str) -> Result<image::ImageReader<std::io::BufReader<std::fs::File>>, LoadError> {
|
||||
let reader =
|
||||
image::ImageReader::open(path).map_err(|error| LoadError::Io(error.to_string()))?;
|
||||
reader
|
||||
/// 原图尺寸(只读图头,不解像素)。
|
||||
fn header_size(bytes: &[u8]) -> Result<(u32, u32), LoadError> {
|
||||
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|error| LoadError::Undecodable(error.to_string()))
|
||||
}
|
||||
|
||||
fn header_size(path: &str) -> Result<(u32, u32), LoadError> {
|
||||
let reader = open(path)?;
|
||||
.map_err(|error| LoadError::Undecodable(error.to_string()))?;
|
||||
if reader.format().is_none() {
|
||||
return Err(LoadError::Undecodable(
|
||||
"unknown image format (magic bytes not recognised)".to_owned(),
|
||||
@@ -168,103 +155,9 @@ fn header_size(path: &str) -> Result<(u32, u32), LoadError> {
|
||||
.map_err(|error| LoadError::Undecodable(error.to_string()))
|
||||
}
|
||||
|
||||
/// 填充几何:源图 → 视口 的映射。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Placement {
|
||||
/// 缩放比:源图 1 像素 = `scale` dp。
|
||||
pub scale: f32,
|
||||
/// 源图左上角落在视口里的位置(dp;`cover` 时是负值 = 已经裁掉溢出)。
|
||||
pub origin: (f32, f32),
|
||||
/// 是否平铺(只有 `tile`)。
|
||||
pub repeat: bool,
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
/// 源图缩放后的尺寸(dp)。
|
||||
#[must_use]
|
||||
pub fn scaled(&self, source: (u32, u32)) -> (f32, f32) {
|
||||
(source.0 as f32 * self.scale, source.1 as f32 * self.scale)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按填充档算一次几何(纯函数)。
|
||||
/// 暗化系数(叠在图上那层底色的 α):`0` = 不暗化,上限 [`BG_DIM_MAX`]。
|
||||
#[must_use]
|
||||
pub fn placement(fill: BgFill, source: (u32, u32), viewport: (f32, f32)) -> Placement {
|
||||
let source_f = (source.0.max(1) as f32, source.1.max(1) as f32);
|
||||
let viewport = (viewport.0.max(1.0), viewport.1.max(1.0));
|
||||
|
||||
let scale = match fill {
|
||||
BgFill::Cover => (viewport.0 / source_f.0).max(viewport.1 / source_f.1),
|
||||
BgFill::Contain => (viewport.0 / source_f.0).min(viewport.1 / source_f.1),
|
||||
BgFill::Tile => 1.0,
|
||||
};
|
||||
let scaled = (source_f.0 * scale, source_f.1 * scale);
|
||||
let origin = match fill {
|
||||
// 平铺从视口左上角起(官方 Contain/Tile 的语义:不居中,直接重复)
|
||||
BgFill::Tile => (0.0, 0.0),
|
||||
_ => ((viewport.0 - scaled.0) / 2.0, (viewport.1 - scaled.1) / 2.0),
|
||||
};
|
||||
|
||||
Placement {
|
||||
scale,
|
||||
origin,
|
||||
repeat: matches!(fill, BgFill::Tile),
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口点 → 源图像素坐标(未取整)。`None` = 该点落在图像之外(`contain` 的留底色区)。
|
||||
#[must_use]
|
||||
pub fn source_point(
|
||||
placement: &Placement,
|
||||
source: (u32, u32),
|
||||
point: (f32, f32),
|
||||
) -> Option<(f32, f32)> {
|
||||
let source_f = (source.0.max(1) as f32, source.1.max(1) as f32);
|
||||
let mut x = (point.0 - placement.origin.0) / placement.scale;
|
||||
let mut y = (point.1 - placement.origin.1) / placement.scale;
|
||||
if placement.repeat {
|
||||
x = x.rem_euclid(source_f.0);
|
||||
y = y.rem_euclid(source_f.1);
|
||||
} else if !(0.0..source_f.0).contains(&x) || !(0.0..source_f.1).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
Some((x, y))
|
||||
}
|
||||
|
||||
/// 取缩略图上一个像素(最近邻;缩略图已经 ≤256 边,够用)。
|
||||
#[must_use]
|
||||
pub fn sample(image: &Image, point: (f32, f32)) -> [u8; 3] {
|
||||
let x = (point.0 as u32).min(image.width.saturating_sub(1));
|
||||
let y = (point.1 as u32).min(image.height.saturating_sub(1));
|
||||
let index = ((y * image.width + x) * 4) as usize;
|
||||
let rgba = &image.rgba;
|
||||
[
|
||||
rgba.get(index).copied().unwrap_or(0),
|
||||
rgba.get(index + 1).copied().unwrap_or(0),
|
||||
rgba.get(index + 2).copied().unwrap_or(0),
|
||||
]
|
||||
}
|
||||
|
||||
/// 暗化:把取样色按 `dim` **混向面板底色**(`rgb + (toward − rgb) × dim`)。
|
||||
///
|
||||
/// 为什么混向底色而不是乘一个「黑」:乘黑在深色主题里正合适,但浅色主题下会让一张亮图变成
|
||||
/// 深色块、而压在其上的深色正文就更难读。混向 `toward`(= 当前主题的 Canvas 色)在两种主题下
|
||||
/// 都是「往底色的方向退」,正文对比度因此单调变好——这与 `appearance.bg_dim` 的用途
|
||||
/// (「让前景压得住背景」)一致。`dim = 0` 时逐字节不变。
|
||||
#[must_use]
|
||||
pub fn dimmed(rgb: [u8; 3], dim: f32, toward: [u8; 3]) -> [u8; 3] {
|
||||
let keep = clamp_dim(dim);
|
||||
let channel = |index: usize| {
|
||||
let from = f32::from(rgb[index]);
|
||||
let to = f32::from(toward[index]);
|
||||
(from + (to - from) * keep).round().clamp(0.0, 255.0) as u8
|
||||
};
|
||||
[channel(0), channel(1), channel(2)]
|
||||
}
|
||||
|
||||
/// 暗化系数的唯一钳位点(0 = 不暗化,0.95 = 上限)。
|
||||
#[must_use]
|
||||
pub fn clamp_dim(dim: f32) -> f32 {
|
||||
pub fn dim_alpha(dim: f32) -> f32 {
|
||||
if dim.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
@@ -272,79 +165,51 @@ pub fn clamp_dim(dim: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次渲染要画的马赛克:按行主序铺的格子色(`None` = 露底色)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Mosaic {
|
||||
/// 平铺档的网格(列 × 行)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Tiles {
|
||||
/// 列数。
|
||||
pub cols: u32,
|
||||
/// 行数。
|
||||
pub rows: u32,
|
||||
/// 每格颜色(`rows × cols`,行主序)。
|
||||
pub cells: Vec<Option<[u8; 3]>>,
|
||||
}
|
||||
|
||||
impl Mosaic {
|
||||
/// 第 `row` 行第 `col` 列的颜色(越界给 `None`)。
|
||||
impl Tiles {
|
||||
/// 总块数。
|
||||
#[must_use]
|
||||
pub fn at(&self, row: u32, col: u32) -> Option<[u8; 3]> {
|
||||
if row >= self.rows || col >= self.cols {
|
||||
return None;
|
||||
}
|
||||
self.cells
|
||||
.get((row * self.cols + col) as usize)
|
||||
.copied()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// 网格尺寸(格边长 dp = `视口 / 格数`,所以调用方可以只用 `Length::Fill` 拼行)。
|
||||
#[must_use]
|
||||
pub fn cell_size(&self, viewport: (f32, f32)) -> (f32, f32) {
|
||||
(
|
||||
viewport.0 / self.cols.max(1) as f32,
|
||||
viewport.1 / self.rows.max(1) as f32,
|
||||
)
|
||||
pub fn count(self) -> u32 {
|
||||
self.cols * self.rows
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口 → 网格尺寸(格边长 ≈ [`CELL_PX`],列/行各有上限)。
|
||||
/// 平铺档要铺几列几行(原图 1 像素 = 1 dp;总块数不超过 [`MAX_TILES`])。
|
||||
///
|
||||
/// 图比视口大时是 `1×1`(等于「以原尺寸裁切」),与小图平铺是同一套算式。
|
||||
#[must_use]
|
||||
pub fn grid(viewport: (f32, f32)) -> (u32, u32) {
|
||||
let cols = div_ceil(viewport.0.max(1.0), CELL_PX).clamp(1, MAX_COLS);
|
||||
let rows = div_ceil(viewport.1.max(1.0), CELL_PX).clamp(1, MAX_ROWS);
|
||||
(cols, rows)
|
||||
}
|
||||
pub fn tiles(source: (u32, u32), viewport: (f32, f32)) -> Tiles {
|
||||
let width = source.0.max(1) as f32;
|
||||
let height = source.1.max(1) as f32;
|
||||
let cols = ceil_div(viewport.0.max(1.0), width);
|
||||
let rows = ceil_div(viewport.1.max(1.0), height);
|
||||
|
||||
/// 算一整张马赛克(纯函数:同一图 + 同一视口 + 同一底色 → 同一结果)。
|
||||
#[must_use]
|
||||
pub fn mosaic(
|
||||
image: &Image,
|
||||
fill: BgFill,
|
||||
viewport: (f32, f32),
|
||||
dim: f32,
|
||||
toward: [u8; 3],
|
||||
) -> Mosaic {
|
||||
let placement = placement(fill, image.source, viewport);
|
||||
let (cols, rows) = grid(viewport);
|
||||
let (cell_w, cell_h) = (
|
||||
viewport.0.max(1.0) / cols as f32,
|
||||
viewport.1.max(1.0) / rows as f32,
|
||||
);
|
||||
|
||||
let mut cells = Vec::with_capacity((cols * rows) as usize);
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let centre = ((col as f32 + 0.5) * cell_w, (row as f32 + 0.5) * cell_h);
|
||||
cells.push(
|
||||
source_point(&placement, image.source, centre)
|
||||
.map(|point| dimmed(sample(image, point), dim, toward)),
|
||||
);
|
||||
// 超预算时按比例退:先保列再保行,至少留 1×1(否则什么都画不出来)。
|
||||
let mut tiles = Tiles {
|
||||
cols: cols.max(1),
|
||||
rows: rows.max(1),
|
||||
};
|
||||
while tiles.count() > MAX_TILES {
|
||||
if tiles.cols > 1 {
|
||||
tiles.cols -= 1;
|
||||
} else if tiles.rows > 1 {
|
||||
tiles.rows -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Mosaic { cols, rows, cells }
|
||||
tiles
|
||||
}
|
||||
|
||||
fn div_ceil(value: f32, step: f32) -> u32 {
|
||||
fn ceil_div(value: f32, step: f32) -> u32 {
|
||||
let count = (value / step).ceil();
|
||||
if count.is_finite() && count >= 1.0 {
|
||||
count as u32
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
//! `background` 的单测:填充几何 / 暗化 / 马赛克 / 解码失败。
|
||||
//! `background` 的单测:解码(png + jpeg)、失败分类、暗化钳位、平铺网格。
|
||||
//!
|
||||
//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律:
|
||||
//! 能断言的(几何、通道值、错误分类)必须断言;观感(马赛克好不好看、真图直出)不在这里假装。
|
||||
//! 每条测试的「抓得住 / 抓不住」写在注释里——这是本 mission 的验收纪律:能断言的不许只靠肉眼。
|
||||
|
||||
use super::{
|
||||
clamp_dim, dimmed, grid, load, mosaic, placement, sample, source_point, BgFill, Image,
|
||||
LoadError, BG_DIM_MAX, MAX_COLS, MAX_ROWS,
|
||||
};
|
||||
use super::{dim_alpha, load, tiles, LoadError, BG_DIM_MAX, MAX_TILES};
|
||||
use std::path::{Path, PathBuf};
|
||||
use unlucky_presenter::prefs::BgFill;
|
||||
|
||||
/// 8×4 的合成图:左半红、右半蓝(缩略图与原图同尺寸,取样结果因此可预测)。
|
||||
fn halves() -> Image {
|
||||
let (width, height) = (8_u32, 4_u32);
|
||||
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
|
||||
for _row in 0..height {
|
||||
for col in 0..width {
|
||||
if col < width / 2 {
|
||||
rgba.extend_from_slice(&[200, 40, 40, 255]);
|
||||
} else {
|
||||
rgba.extend_from_slice(&[40, 60, 200, 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Image::from_rgba((width, height), width, height, rgba)
|
||||
}
|
||||
|
||||
/// 一整块纯色的合成图(只关心几何时用)。
|
||||
fn solid(size: (u32, u32), rgb: [u8; 3]) -> Image {
|
||||
let rgba = [rgb[0], rgb[1], rgb[2], 255].repeat((size.0 * size.1) as usize);
|
||||
Image::from_rgba(size, size.0, size.1, rgba)
|
||||
}
|
||||
|
||||
fn temp_png(label: &str) -> PathBuf {
|
||||
fn temp_file(label: &str, name: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ull-bg-{label}-{}-{}", std::process::id(), line!()));
|
||||
std::fs::create_dir_all(&dir).expect("临时目录");
|
||||
dir.join("bg.png")
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
fn cleanup(path: &Path) {
|
||||
@@ -45,131 +20,63 @@ fn cleanup(path: &Path) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_fills_the_viewport_and_contain_keeps_the_aspect_ratio() {
|
||||
// 抓得住:两种档的 scale 公式(铺满 vs 完整)与居中偏移的正负号。
|
||||
// 抓不住:真实的裁切/留白在屏幕上的观感(那要看截图)。
|
||||
let source = (8, 4); // 2:1
|
||||
let viewport = (400.0, 400.0);
|
||||
fn a_real_png_decodes_with_its_original_size_and_raw_bytes() {
|
||||
// 抓得住:真磁盘 PNG 能读、原图尺寸按图头、缩略图不超预算、原始字节留着给渲染器。
|
||||
// 抓不住:渲染器用这些字节画出来的颜色(那要看截图)。
|
||||
let path = temp_file("png", "bg.png");
|
||||
let source: image::RgbImage = image::RgbImage::from_pixel(300, 20, image::Rgb([12, 200, 90]));
|
||||
source.save(&path).expect("写 png");
|
||||
|
||||
// cover = max(400/8 = 50, 400/4 = 100) = 100 → 缩放后 800×400,横向溢出 400 被裁。
|
||||
let cover = placement(BgFill::Cover, source, viewport);
|
||||
assert_eq!(cover.scale, 100.0, "cover = max(50, 100)");
|
||||
assert_eq!(cover.origin, (-200.0, 0.0), "cover 居中后横向溢出被裁");
|
||||
|
||||
// contain = min(50, 100) = 50 → 缩放后 400×200,上下各留 100 的底色。
|
||||
let contain = placement(BgFill::Contain, source, viewport);
|
||||
assert_eq!(contain.scale, 50.0);
|
||||
assert_eq!(contain.origin, (0.0, 100.0), "contain 上下留底色");
|
||||
assert!(!contain.repeat && !cover.repeat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_never_leaves_a_hole_and_contain_does() {
|
||||
let image = solid((8, 4), [10, 20, 30]);
|
||||
let viewport = (360.0, 360.0);
|
||||
|
||||
let covered = mosaic(&image, BgFill::Cover, viewport, 0.0, [0, 0, 0]);
|
||||
let image = load(path.to_str().unwrap()).expect("真 PNG 必须能读");
|
||||
assert_eq!(image.source, (300, 20));
|
||||
assert!(
|
||||
covered.cells.iter().all(Option::is_some),
|
||||
"cover 必须铺满(任何一格都不许是 None)"
|
||||
image.width <= super::MAX_EDGE && image.height <= super::MAX_EDGE,
|
||||
"缩略图 {}×{} 超预算",
|
||||
image.width,
|
||||
image.height
|
||||
);
|
||||
|
||||
let contained = mosaic(&image, BgFill::Contain, viewport, 0.0, [0, 0, 0]);
|
||||
assert_eq!(contained.at(0, 0), None, "contain 的左上角是留底色");
|
||||
assert_eq!(
|
||||
contained.at(0, 0),
|
||||
None,
|
||||
"(同一格重复读:确保 at() 不是偶发给 None)"
|
||||
);
|
||||
let middle = (contained.rows / 2, contained.cols / 2);
|
||||
assert!(contained.at(middle.0, middle.1).is_some(), "中间必须有图");
|
||||
assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize);
|
||||
assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize);
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
/// M93 r2 的硬要求:`.jpg` 必须能加载(用户挑的背景图/壁纸多半是 jpeg)。
|
||||
///
|
||||
/// 抓得住:`image` 的 jpeg 编码器真的开了(没开时 `load` 会返回 `Undecodable`)。
|
||||
/// 抓不住:渲染器那边(`iced/image`)解码 jpeg 的能力——那条由截图矩阵里那张 jpg 出图举证。
|
||||
#[test]
|
||||
fn tile_repeats_the_original_size_art_grid() {
|
||||
// 抓得住:tile 的 scale 恒 1、origin 在左上角、坐标按图宽/图高取模。
|
||||
// 抓不住:平铺后每格之间是否有一像素缝(那是渲染层的事)。
|
||||
let image = halves();
|
||||
let tile = placement(BgFill::Tile, image.source, (40.0, 40.0));
|
||||
assert_eq!(tile, tile);
|
||||
assert_eq!(tile.scale, 1.0);
|
||||
assert_eq!(tile.origin, (0.0, 0.0));
|
||||
assert!(tile.repeat);
|
||||
fn a_real_jpeg_decodes_too() {
|
||||
let path = temp_file("jpeg", "bg.jpg");
|
||||
let source =
|
||||
image::RgbImage::from_fn(64, 32, |x, y| image::Rgb([x as u8 * 4, y as u8 * 8, 90]));
|
||||
source
|
||||
.save_with_format(&path, image::ImageFormat::Jpeg)
|
||||
.expect("写 jpeg");
|
||||
|
||||
// 同一源坐标:x 与 x + 图宽 必须取到同一像素。
|
||||
let left = source_point(&tile, image.source, (3.5, 1.5)).expect("平铺内部总有图");
|
||||
let wrapped = source_point(&tile, image.source, (3.5 + 8.0 * 3.0, 1.5 + 4.0 * 2.0))
|
||||
.expect("取模后仍在图内");
|
||||
assert!((left.0 - wrapped.0).abs() < 1e-4 && (left.1 - wrapped.1).abs() < 1e-4);
|
||||
|
||||
// 左半红 / 右半蓝:x=1 取红、x=6 取蓝(平铺不改变这一点)。
|
||||
let image = load(path.to_str().unwrap()).expect("jpeg 必须能读(M93 r2)");
|
||||
assert_eq!(image.source, (64, 32));
|
||||
assert_eq!(
|
||||
sample(
|
||||
&image,
|
||||
source_point(&tile, image.source, (1.0, 1.0)).unwrap()
|
||||
),
|
||||
[200, 40, 40]
|
||||
image.rgba.len(),
|
||||
(image.width * image.height * 4) as usize,
|
||||
"像素解出来了"
|
||||
);
|
||||
assert_eq!(
|
||||
sample(
|
||||
&image,
|
||||
source_point(&tile, image.source, (6.0, 1.0)).unwrap()
|
||||
),
|
||||
[40, 60, 200]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dim_mixes_toward_the_panel_colour_and_is_clamped() {
|
||||
// 抓得住:暗化 = 向底色方向插值(不是简单乘黑);dim 越界被夹到 0..=0.95。
|
||||
// 语义的关键在浅色主题:向白退(而不是乘黑)才能让深色正文继续读得清。
|
||||
// 抓不住:0.35 这个默认值在真实壁纸上的观感(视觉判断,见报告「没验的部分」)。
|
||||
assert_eq!(dimmed([200, 100, 50], 0.0, [255, 255, 255]), [200, 100, 50]);
|
||||
assert_eq!(
|
||||
dimmed([200, 100, 50], 0.35, [0, 0, 0]),
|
||||
[130, 65, 33],
|
||||
"深色主题:向黑退"
|
||||
);
|
||||
assert_eq!(
|
||||
dimmed([200, 100, 50], 0.35, [255, 255, 255]),
|
||||
[219, 154, 122],
|
||||
"浅色主题:向白退"
|
||||
);
|
||||
assert_eq!(clamp_dim(-1.0), 0.0);
|
||||
assert_eq!(clamp_dim(f32::NAN), 0.0);
|
||||
assert_eq!(clamp_dim(9.0), BG_DIM_MAX);
|
||||
assert_eq!(
|
||||
dimmed([255, 255, 255], 9.0, [0, 0, 0]),
|
||||
[13, 13, 13],
|
||||
"夹到 0.95 → 只剩 5% 原色"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_is_bounded_so_the_mosaic_cannot_explode() {
|
||||
// 抓得住:超宽/超高视口下格数被上限截住(渲染成本有界),且至少 1×1。
|
||||
// 抓不住:这个上限在真机上到底花多少毫秒(没做基准)。
|
||||
assert_eq!(grid((1180.0, 760.0)), (33, 22));
|
||||
let (cols, rows) = grid((100_000.0, 100_000.0));
|
||||
assert_eq!((cols, rows), (MAX_COLS, MAX_ROWS));
|
||||
assert_eq!(grid((0.0, 0.0)), (1, 1));
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_or_broken_file_reports_a_readable_reason() {
|
||||
// 抓得住:①路径不存在 → Io;②文件存在但不是图片 → Undecodable;③空路径 → Io。
|
||||
// 两条都必须是 **Err**(不许静默回一个默认色),且 describe() 里不含 panic 文案。
|
||||
// 抓不住:本 build 只带 png 编解码器,jpeg/bmp 会在 Undecodable 里出现
|
||||
// (「Unsigned format」/「The image format ... is not supported」)。
|
||||
let missing = temp_png("missing");
|
||||
// 都必须是 Err(不许静默回默认色),且 describe() 是人话。
|
||||
// 抓不住:webp/tiff 这类没开编码器的格式具体报什么字符串(只钉分类,不钉解码库原文)。
|
||||
let missing = temp_file("missing", "bg.png");
|
||||
let error = load(missing.to_str().unwrap()).expect_err("不存在的路径必须报错");
|
||||
assert!(matches!(error, LoadError::Io(_)), "实际 {error:?}");
|
||||
assert!(error
|
||||
.describe()
|
||||
.starts_with("Background image could not be read"));
|
||||
|
||||
let broken = temp_png("broken");
|
||||
std::fs::write(&broken, b"this is not a png").expect("写坏文件");
|
||||
let broken = temp_file("broken", "bg.png");
|
||||
std::fs::write(&broken, b"this is not an image").expect("写坏文件");
|
||||
let error = load(broken.to_str().unwrap()).expect_err("非图片必须报错");
|
||||
assert!(matches!(error, LoadError::Undecodable(_)), "实际 {error:?}");
|
||||
cleanup(&broken);
|
||||
@@ -179,22 +86,39 @@ fn a_missing_or_broken_file_reports_a_readable_reason() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_png_decodes_and_is_scaled_down_to_the_sample_budget() {
|
||||
// 抓得住:真磁盘 PNG 能读出来、原图尺寸保留、缩略图边长不超过 MAX_EDGE。
|
||||
// 抓不住:解码后的颜色是否与它在屏幕上一致(色彩管理不在本 mission)。
|
||||
let path = temp_png("ok");
|
||||
let source: image::RgbImage = image::RgbImage::from_pixel(300, 20, image::Rgb([12, 200, 90]));
|
||||
source.save(&path).expect("写 png");
|
||||
fn dim_is_clamped_to_the_supported_range() {
|
||||
// 抓得住:暗化系数夹在 0..=0.95(下限允许 0 = 不暗化,上限防「背景看不见」),NaN 取 0。
|
||||
// 抓不住:0.35 这个默认值在真实壁纸上的观感(视觉判断)。
|
||||
assert_eq!(dim_alpha(0.0), 0.0);
|
||||
assert_eq!(dim_alpha(0.35), 0.35);
|
||||
assert_eq!(dim_alpha(-1.0), 0.0);
|
||||
assert_eq!(dim_alpha(f32::NAN), 0.0);
|
||||
assert_eq!(dim_alpha(9.0), BG_DIM_MAX);
|
||||
assert_eq!(BG_DIM_MAX, 0.95, "与 core 同源(presenter 重导出)");
|
||||
}
|
||||
|
||||
let image = load(path.to_str().unwrap()).expect("真 PNG 必须能读");
|
||||
assert_eq!(image.source, (300, 20), "原图尺寸按图头");
|
||||
assert!(
|
||||
image.width <= super::MAX_EDGE && image.height <= super::MAX_EDGE,
|
||||
"缩略图边 {}×{} 超预算",
|
||||
image.width,
|
||||
image.height
|
||||
);
|
||||
assert_eq!(image.rgba.len(), (image.width * image.height * 4) as usize);
|
||||
assert_eq!(sample(&image, (0.0, 0.0)), [12, 200, 90], "纯色图取样不变");
|
||||
cleanup(&path);
|
||||
#[test]
|
||||
fn tile_geometry_counts_enough_tiles_and_stays_within_budget() {
|
||||
// 抓得住:①图比视口小时按原尺寸铺够(ceil);②图比视口大时是 1×1(= 原尺寸裁切);
|
||||
// ③总块数永远 ≤ MAX_TILES(小图不会拼出上万个控件)。
|
||||
// 抓不住:铺出来的缝(渲染层的事,看截图)。
|
||||
let small = tiles((200, 200), (640.0, 480.0));
|
||||
assert_eq!((small.cols, small.rows), (4, 3));
|
||||
assert_eq!(small.count(), 12);
|
||||
|
||||
let big = tiles((1600, 900), (1180.0, 760.0));
|
||||
assert_eq!((big.cols, big.rows), (1, 1), "图比视口大 ⇒ 以原尺寸裁切");
|
||||
|
||||
let tiny = tiles((16, 16), (3840.0, 2160.0));
|
||||
assert!(tiny.count() <= MAX_TILES, "{tiny:?} 超预算");
|
||||
assert!(tiny.cols >= 1 && tiny.rows >= 1, "至少铺一块");
|
||||
}
|
||||
|
||||
/// 编译期钉住:填充档解析走 core 的词表(`cover`/`contain`/`tile`)。
|
||||
#[test]
|
||||
fn fill_keys_are_the_schema_vocabulary() {
|
||||
// 抓得住:有人改了落盘词表却没改这里(截图矩阵的 env 也按这三个词传)。
|
||||
assert_eq!(BgFill::from_key_value("cover"), BgFill::Cover);
|
||||
assert_eq!(BgFill::from_key_value("contain"), BgFill::Contain);
|
||||
assert_eq!(BgFill::from_key_value("tile"), BgFill::Tile);
|
||||
}
|
||||
@@ -20,20 +20,21 @@
|
||||
//! | [`background`] | 背景图解码、三种填充几何、暗化、马赛克取样 | 读盘一次(`load`) |
|
||||
//! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) |
|
||||
//! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` |
|
||||
//! | `section` | 设置页「外观」分组(真控件)+ 背景层 Element | 无 |
|
||||
//! | `section` | 设置页「外观」分组(真控件) | 无 |
|
||||
//! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 |
|
||||
//!
|
||||
//! ## 偏离登记(D30 要求逐条写清)
|
||||
//!
|
||||
//! | # | 偏离 | 为什么 | 什么时候收口 |
|
||||
//! |---|---|---|---|
|
||||
//! | 1 | 背景图按 **36dp 网格马赛克**画,不是逐像素直出 | iced 0.14 的 `widget::image` 要 `iced/image` feature,本 crate 的 `Cargo.toml` 不在本 mission 的 scope。填充几何 / 暗化 / 取色全是真值,只有「每格一个颜色」是降级 | 开 `iced/image` feature 的 mission(一行 Cargo.toml) |
|
||||
//! | 2 | 面板 α 只作用到**外壳层**(根容器 / 标题栏 / 导航轨)与本模块画的卡片 | `pages/frame/pieces.rs` 的 `card()` 是其它三页共用的实色卡片,不在本 mission 的 scope。于是「有背景图 + 面板 α<1」时,实例/下载/日志页的卡片仍是实色块 | 把 `card()` 的底色改成「面板层」(一个函数,改完三页同时生效) |
|
||||
//! | 1 | ~~背景图按 36dp 马赛克画~~ → **r2 已销**:`iced/image` 打开,真图直出(`ContentFit` + 平铺网格) | r1 时 `Cargo.toml` 不在 scope;M93 r2 由 tower 扩 scope 解决 | ✅ |
|
||||
//! | 2 | ~~面板 α 只覆盖外壳层~~ → **r2 已销**:α 收敛到 `Ui::layer`(唯一施加点),`frame::card` / 页头带 / 外壳 / 外观卡片自动同源 | M93 r2 扩了 `pages/frame.rs` + `theme.rs` 的 scope | ✅ |
|
||||
//! | 3 | 桌面壁纸取色(第二级)永远 `Absent` | 壁纸探测(M73)还没落地;优先序本身仍按 design 的三级走,测试里用合成像素证明第二级会被用到 | M73 |
|
||||
//! | 4 | 色卡档**不换** `Ui` 的 scheme | `resolve_source` 的色卡档返回 `DEFAULT_CARD`(天蓝卡)的方案,与令牌 baseline 逐色不同;应用它会把全部既有验收图的像素改掉。取色档(①②)才换 | 色卡选择 UI 落地时一并决议 |
|
||||
//! | 5 | `Backend::Auto` 不触发 CPU 档降级 | 「强制 1.0」是 CPU 渲染档的规则,而 `Auto` 是「探测前的请求值」;探测结果没有回灌 `Backend` | 让 `probe` 把结果回灌 `Options::backend` |
|
||||
//! | 6 | 背景图解码只带 png 编解码器 | `crates/app-iced` 的 `image` 只开了 `png`(现有截图通道用),`Cargo.toml` 在 scope 外。jpeg/bmp 会走 `LoadError::Undecodable` 并给出可读提示 | 把 `image` 的 `jpeg`/`bmp` feature 打开(或改走 `unlucky-palette-extract`) |
|
||||
//! | 6 | ~~背景图只支持 png~~ → **r2 已销**:`image` 补了 `jpeg`/`bmp`,单测与截图都用 `.jpg` 举证 | 同上 | ✅ |
|
||||
//! | 7 | `bg_dim` 是「往**面板底色**退」,不是「叠黑」 | 浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清;向底色退在两种主题下都单调变好(见 [`background::dimmed`]) | 若将来为了「夜间压暗」需要真叠黑,加一档独立键(不要复用这个) |
|
||||
//! | 8 | 页头副标题 / 分组标签这类**直接压在画布上**的次要文字,在 `panel_alpha < 1` 时对比度下降 | 它们由 `pages/frame.rs` 的页头画,而那个文件不在本 mission 的 scope;真正该改的是「页头带显示面板底色」 | 把页头带改成面板层(`frame::header_bar` 一处) |
|
||||
//! | 8 | ~~页头文字压在画布上~~ → **r2 已销**:`frame::header_bar` 加了面板底色 | M93 r2 扩了 `pages/frame.rs` 的 scope | ✅ |
|
||||
//!
|
||||
//! ## 取证
|
||||
//!
|
||||
@@ -45,10 +46,12 @@ pub mod alpha;
|
||||
pub mod background;
|
||||
|
||||
mod palette;
|
||||
mod picture;
|
||||
mod section;
|
||||
mod state;
|
||||
|
||||
pub use section::{layer, panel_color, section};
|
||||
pub use picture::layer;
|
||||
pub use section::section;
|
||||
pub use state::State;
|
||||
|
||||
/// 设置页「外观」分组在左列里的下标(0 = General,1 = Appearance …)。
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 背景层的**像素**:真图直出(`Cover` / `Contain` / `Tile`)+ 暗化叠层。
|
||||
//!
|
||||
//! 与 [`super::section`] 拆开只为单文件 ≤400 行的纪律;对外入口仍是
|
||||
//! `pages::appearance::layer`(本文件 [`layer`] 的再导出)。
|
||||
//!
|
||||
//! ## 为什么是「真图」
|
||||
//!
|
||||
//! M93 r1 走的是「36dp 网格马赛克」(那时 iced 的 `image` feature 没开)。r2 打开后改成本文件:
|
||||
//! `Cover`/`Contain` 交给渲染器的 `ContentFit`(等比缩放 + 裁切/留白由它做),`Tile` 用
|
||||
//! [`background::tiles`] 算行列数后真的铺 N 个图块(`ContentFit::None` = 原尺寸)。
|
||||
//!
|
||||
//! 暗化不是逐像素改色,而是**盖一层「版面底色 @ α=dim」**:图不透明时它与「向底色插值」
|
||||
//! 数学等价([`background::dim_alpha`] 有推导),而普通 alpha 混合 CPU/GPU 两档都支持。
|
||||
|
||||
use iced::widget::image;
|
||||
use iced::widget::{column, container, image as image_widget, space};
|
||||
use iced::{ContentFit, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_presenter::prefs::BgFill;
|
||||
|
||||
use crate::pages::{frame, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::background;
|
||||
use super::state::State;
|
||||
|
||||
/// 背景层(铺满整窗、垫在最下层)。`None` = 没有可用的背景图(调用方就不叠这一层)。
|
||||
///
|
||||
/// 真图直出(M93 r2):`Cover` / `Contain` 交给 iced 的 `ContentFit`,`Tile` 用
|
||||
/// [`background::tiles`] 算网格铺 N 个图块。暗化 = 一层「版面底色 @ α=dim」盖在图上
|
||||
/// (见 [`background::dim_alpha`] 的等价性说明)。
|
||||
#[must_use]
|
||||
pub fn layer(ui: &Ui, state: &State, viewport: (f32, f32)) -> Option<Element<'static, Message>> {
|
||||
let picture = state.image()?;
|
||||
if !state.bg_enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let handle = image_handle(state.bg_path.trim());
|
||||
let art: Element<'static, Message> = match state.bg_fill {
|
||||
BgFill::Cover => fitted(handle, ContentFit::Cover),
|
||||
BgFill::Contain => fitted(handle, ContentFit::Contain),
|
||||
BgFill::Tile => tiled(handle, picture.source, viewport),
|
||||
};
|
||||
|
||||
// 暗化:底色 @ α=dim 盖在图上(等价于逐像素向底色插值,见 `background::dim_alpha`)。
|
||||
let scrim_color = ui
|
||||
.color(Role::Surface)
|
||||
.scale_alpha(background::dim_alpha(state.bg_dim));
|
||||
let scrim: Element<'static, Message> = container(space::horizontal())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| frame::filled(scrim_color))
|
||||
.into();
|
||||
|
||||
Some(
|
||||
iced::widget::stack![art, scrim]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 文件字节 → iced 的图片句柄(渲染器自己解复用,不再读盘)。
|
||||
fn image_handle(path: &str) -> image::Handle {
|
||||
image::Handle::from_path(path)
|
||||
}
|
||||
|
||||
/// `Cover` / `Contain`:交给渲染器的等比缩放档。
|
||||
fn fitted(handle: image::Handle, fit: ContentFit) -> Element<'static, Message> {
|
||||
image_widget(handle)
|
||||
.content_fit(fit)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// `Tile`:原图尺寸平铺(块数上限见 [`background::MAX_TILES`])。
|
||||
fn tiled(
|
||||
handle: image::Handle,
|
||||
source: (u32, u32),
|
||||
viewport: (f32, f32),
|
||||
) -> Element<'static, Message> {
|
||||
let tiles = background::tiles(source, viewport);
|
||||
let (width, height) = (source.0.max(1) as f32, source.1.max(1) as f32);
|
||||
|
||||
let rows = (0..tiles.rows)
|
||||
.map(|_| {
|
||||
let cells = (0..tiles.cols)
|
||||
.map(|_| -> Element<'static, Message> {
|
||||
image_widget(handle.clone())
|
||||
.content_fit(ContentFit::None)
|
||||
.width(Length::Fixed(width))
|
||||
.height(Length::Fixed(height))
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
iced::widget::row(cells).into()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
column(rows).width(Length::Fill).height(Length::Fill).into()
|
||||
}
|
||||
@@ -44,56 +44,6 @@ use super::background::BG_DIM_MAX;
|
||||
use super::state::State;
|
||||
use super::Change;
|
||||
|
||||
/// 一处面板/卡片表面色:令牌色 × 生效的面板 α。
|
||||
///
|
||||
/// `alpha == 1.0` 时返回**逐字节相同**的令牌色(`scale_alpha(1.0)` 不引入误差),所以没开面板
|
||||
/// 透明度时不会动到任何既有验收图的像素。
|
||||
#[must_use]
|
||||
pub fn panel_color(ui: &Ui, layer: MaterialLayer, alpha: f32) -> Color {
|
||||
let alpha = if alpha.is_finite() {
|
||||
alpha.clamp(0.0, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
ui.layer(layer).scale_alpha(alpha)
|
||||
}
|
||||
|
||||
/// 背景层(铺满整窗、垫在最下面的马赛克)。`None` = 没有背景图(调用方就不叠这一层)。
|
||||
#[must_use]
|
||||
pub fn layer(ui: &Ui, state: &State, viewport: (f32, f32)) -> Option<Element<'static, Message>> {
|
||||
let fallback = ui.layer(MaterialLayer::Canvas);
|
||||
let mosaic = state.mosaic(viewport, rgb8(fallback))?;
|
||||
|
||||
let rows = (0..mosaic.rows)
|
||||
.map(|row| {
|
||||
let cells = (0..mosaic.cols)
|
||||
.map(|col| cell(mosaic.at(row, col), fallback))
|
||||
.collect::<Vec<_>>();
|
||||
iced::widget::row(cells)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(column(rows).width(Length::Fill).height(Length::Fill).into())
|
||||
}
|
||||
|
||||
/// iced 颜色 → 8 bit RGB(暗化的「底色」方向)。
|
||||
fn rgb8(color: Color) -> [u8; 3] {
|
||||
let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
[channel(color.r), channel(color.g), channel(color.b)]
|
||||
}
|
||||
|
||||
fn cell<'a>(color: Option<[u8; 3]>, fallback: Color) -> Element<'a, Message> {
|
||||
let color = color.map_or(fallback, |[r, g, b]| Color::from_rgb8(r, g, b));
|
||||
container(iced::widget::space::horizontal())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| frame::filled(color))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 「外观」分组整块(一张卡)。
|
||||
pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
|
||||
let mut rows = column![].spacing(ui.space(Space::X1)).width(Length::Fill);
|
||||
@@ -250,16 +200,13 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
|
||||
let resolved = format!("Colours: {}", state.palette_level());
|
||||
rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant)));
|
||||
|
||||
card_with_panel_alpha(ui, state, rows)
|
||||
card(ui, rows)
|
||||
}
|
||||
|
||||
fn card_with_panel_alpha<'a>(
|
||||
ui: &'a Ui,
|
||||
state: &State,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
|
||||
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
let spec = Component::OutlinedCard.spec();
|
||||
let background = panel_color(ui, MaterialLayer::Card, state.effective_panel_alpha());
|
||||
let background = ui.layer(MaterialLayer::Card);
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
|
||||
@@ -22,7 +22,7 @@ use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs, BgFill, PaletteS
|
||||
use crate::Backend;
|
||||
|
||||
use super::alpha::{self, effective_glass, effective_panel_alpha, panel_is_forced_opaque};
|
||||
use super::background::{self, Image, Mosaic};
|
||||
use super::background::{self, Image};
|
||||
use super::palette::{self, Resolved, Source};
|
||||
|
||||
/// 取证开关的环境变量名(截图矩阵与真机日志用;生产默认全部不设)。
|
||||
@@ -166,24 +166,6 @@ impl State {
|
||||
self.image.as_ref()
|
||||
}
|
||||
|
||||
/// 算一次马赛克(渲染层每帧调一次;纯函数,成本 = 格数)。
|
||||
///
|
||||
/// `toward` 是当前主题的面板底色(暗化往它退,见 [`background::dimmed`])。
|
||||
#[must_use]
|
||||
pub fn mosaic(&self, viewport: (f32, f32), toward: [u8; 3]) -> Option<Mosaic> {
|
||||
let image = self.image.as_ref()?;
|
||||
if !self.bg_enabled {
|
||||
return None;
|
||||
}
|
||||
Some(background::mosaic(
|
||||
image,
|
||||
self.bg_fill,
|
||||
viewport,
|
||||
self.bg_dim,
|
||||
toward,
|
||||
))
|
||||
}
|
||||
|
||||
/// 取色得到的方案(`None` = 色卡档,继续用令牌 baseline)。
|
||||
///
|
||||
/// 为什么色卡档不换 scheme:`resolve_source` 的色卡档走的是 `DEFAULT_CARD`(天蓝卡),
|
||||
|
||||
@@ -32,6 +32,7 @@ use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
@@ -122,6 +123,8 @@ fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Elem
|
||||
.spacing(HEADER_ACTION_GAP)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
// 页头带也要有面板底色(M93):否则「开背景图 + 面板 α<1」时标题/副标题直接压在图上,
|
||||
// 浅色主题的副标题(OnSurfaceVariant)对比度掉得厉害。底色同源于 `Ui::layer`(含面板 α)。
|
||||
container(
|
||||
row![titles.width(Length::Fill), actions]
|
||||
.width(Length::Fill)
|
||||
@@ -131,6 +134,7 @@ fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Elem
|
||||
.height(ui.metrics().header_height(header.subtitle.is_some()))
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding::from([0.0, metrics.gutter]))
|
||||
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||
.into()
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ pub fn empty_state<'a>(
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.color(Role::SurfaceContainerHigh))),
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
|
||||
border: Border {
|
||||
radius: ui.radius_dp(spec.shape),
|
||||
..Border::default()
|
||||
|
||||
@@ -34,7 +34,6 @@ use unlucky_design::typography::TypeRole;
|
||||
use crate::app::Message;
|
||||
use crate::cli::ForcedState;
|
||||
use crate::icons;
|
||||
use crate::pages::appearance::panel_color;
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 整棵树(**内容槽位版**:外壳画标题栏 / 缩放热区 / 导航轨,中间那块交给调用方)。
|
||||
@@ -52,8 +51,8 @@ use crate::theme::Ui;
|
||||
/// 而页面自己的 `layout::frame_metrics` 仍以**窗口宽**为输入(它内部会再减 rail 与页边距),
|
||||
/// 两边的账不会算串。
|
||||
///
|
||||
/// M93 起面板 α 是**外壳层**的属性(根容器 / 标题栏 / 导航轨的底色按它乘 alpha):要背景层的
|
||||
/// 调用方走 [`view_with_background`]。
|
||||
/// 面板 α(D36 / M93)不从这里传:它是**表面色**的属性,唯一施加点是 `Ui::layer`
|
||||
/// (`crate::theme`)。外壳的根容器 / 标题栏 / 导航轨都走 `ui.layer(..)`,因此自动同源。
|
||||
pub fn view_with_content<'a>(
|
||||
ui: &'a Ui,
|
||||
selected: NavItem,
|
||||
@@ -61,7 +60,7 @@ pub fn view_with_content<'a>(
|
||||
content: Element<'a, Message>,
|
||||
forced: Option<ForcedState>,
|
||||
) -> Element<'a, Message> {
|
||||
view_with_background(ui, selected, app_title, None, 1.0, content, forced)
|
||||
view_with_background(ui, selected, app_title, None, content, forced)
|
||||
}
|
||||
|
||||
/// 同 [`view_with_content`],但可以在整棵树**最底下**垫一层背景(M93 的背景图马赛克)。
|
||||
@@ -73,20 +72,19 @@ pub fn view_with_background<'a>(
|
||||
selected: NavItem,
|
||||
app_title: &'a str,
|
||||
background: Option<Element<'a, Message>>,
|
||||
panel_alpha: f32,
|
||||
content: Element<'a, Message>,
|
||||
forced: Option<ForcedState>,
|
||||
) -> Element<'a, Message> {
|
||||
let work_area = column![
|
||||
titlebar(ui, app_title, forced, panel_alpha),
|
||||
row![nav::rail(ui, selected, forced, panel_alpha), content]
|
||||
titlebar(ui, app_title, forced),
|
||||
row![nav::rail(ui, selected, forced), content]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
window_frame(ui, background, panel_alpha, work_area.into())
|
||||
window_frame(ui, background, work_area.into())
|
||||
}
|
||||
|
||||
/// 整棵树(M68 骨架版:内容区是「页头 + 一张占位卡」)。
|
||||
@@ -112,12 +110,9 @@ pub fn view<'a>(
|
||||
|
||||
/// 无边框窗口的「外壳壳子」:背景层 + 工作区 + 两条缩放热区 + 根容器底色。
|
||||
///
|
||||
/// 面板 α 落在两处:根容器底色(= 整窗 Canvas 层)与标题栏 / 导航轨底色。`panel_alpha == 1.0`
|
||||
/// 时颜色逐字节不变,所以没开面板透明度时不动既有验收图。
|
||||
fn window_frame<'a>(
|
||||
ui: &'a Ui,
|
||||
background: Option<Element<'a, Message>>,
|
||||
panel_alpha: f32,
|
||||
work_area: Element<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
// 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。
|
||||
@@ -150,7 +145,10 @@ fn window_frame<'a>(
|
||||
.height(Length::Fill)
|
||||
.align_y(Alignment::End);
|
||||
|
||||
// 背景在最底层:面板 α < 1 时从它的空隙里透出来(M93 / D34)。
|
||||
// 背景在最底层(M93 / D34)。有背景层时**根容器不再盖实色画布**:否则那张不透明的
|
||||
// `Canvas` 会把背景图整块挡住(r2 实测:panel α=1 时背景完全看不见)。
|
||||
// 各面板(标题栏 / 导航轨 / 卡片 / 页头带)自己画底色,空隙里露出的就是背景图。
|
||||
let has_background = background.is_some();
|
||||
let shell = match background {
|
||||
Some(layer) => stack![layer, work_area, east, south],
|
||||
None => stack![work_area, east, south],
|
||||
@@ -159,7 +157,13 @@ fn window_frame<'a>(
|
||||
container(shell)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| filled(panel_color(ui, MaterialLayer::Canvas, panel_alpha)))
|
||||
.style(move |_theme| {
|
||||
if has_background {
|
||||
container::Style::default()
|
||||
} else {
|
||||
filled(ui.layer(MaterialLayer::Canvas))
|
||||
}
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -182,12 +186,7 @@ fn resize_edge<'a>(
|
||||
|
||||
// ---------------------------------------------------------------- 标题栏
|
||||
|
||||
fn titlebar<'a>(
|
||||
ui: &'a Ui,
|
||||
title: &'a str,
|
||||
forced: Option<ForcedState>,
|
||||
panel_alpha: f32,
|
||||
) -> Element<'a, Message> {
|
||||
fn titlebar<'a>(ui: &'a Ui, title: &'a str, forced: Option<ForcedState>) -> Element<'a, Message> {
|
||||
let metrics = ui.metrics();
|
||||
|
||||
// 拖动热区:从窗口左缘到第一个按钮(按钮自己不能被拖区吞掉,所以只包左侧一段)
|
||||
@@ -219,7 +218,7 @@ fn titlebar<'a>(
|
||||
container(bar)
|
||||
.width(Length::Fill)
|
||||
.height(metrics.titlebar_height)
|
||||
.style(move |_theme| filled(panel_color(ui, MaterialLayer::Canvas, panel_alpha)))
|
||||
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||
.into()
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ use unlucky_design::typography::TypeRole;
|
||||
use crate::app::Message;
|
||||
use crate::cli::ForcedState;
|
||||
use crate::icons;
|
||||
use crate::pages::appearance::panel_color;
|
||||
use crate::shell::{filled, label, overridden};
|
||||
use crate::theme::Ui;
|
||||
|
||||
@@ -82,7 +81,6 @@ pub(super) fn rail<'a>(
|
||||
ui: &'a Ui,
|
||||
selected: NavItem,
|
||||
forced: Option<ForcedState>,
|
||||
panel_alpha: f32,
|
||||
) -> Element<'a, Message> {
|
||||
let metrics = ui.metrics();
|
||||
let indicator = indicator_size(metrics);
|
||||
@@ -95,12 +93,12 @@ pub(super) fn rail<'a>(
|
||||
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
|
||||
// spacing(4)再加一档,实测变 28(视觉复审 P3-5)。
|
||||
//
|
||||
// 轨底色也吃面板 α(M93):导航轨是「面板层」的一部分,背景图应该能透过它。
|
||||
// 轨底色与其它面板同源(M93):`ui.layer` 已含面板 α,导航轨是面板层的一部分。
|
||||
container(column(items).spacing(metrics.nav_item_gap))
|
||||
.width(nav_rail_width(metrics))
|
||||
.height(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X6), 0.0]))
|
||||
.style(move |_theme| filled(panel_color(ui, MaterialLayer::Canvas, panel_alpha)))
|
||||
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||
.into()
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -13,7 +13,12 @@ pub use unlucky_core::setting::appearance::PALETTE_SOURCES;
|
||||
///
|
||||
/// [`WINDOW_ALPHA_MIN`] 还要给 `unlucky-app-iced` 的 win32 α 层用(它只依赖本 crate,不直接依赖
|
||||
/// core):窗口 α 的硬底必须与设置侧同源,两边各写一个 `0.08` 迟早会漂。
|
||||
pub use unlucky_core::setting::appearance::{PaletteSource, TitlebarMode, WINDOW_ALPHA_MIN};
|
||||
///
|
||||
/// 同理 [`PANEL_ALPHA_MIN`](0.4)与 [`BG_DIM_MAX`](0.95)也在这里重导出:M93 的 UI 侧要按它们
|
||||
/// 钳位滑块与暗化,而 `app-iced` 不直接依赖 core——不重导出就只能各抄一份字面量。
|
||||
pub use unlucky_core::setting::appearance::{
|
||||
PaletteSource, TitlebarMode, BG_DIM_MAX, PANEL_ALPHA_MIN, WINDOW_ALPHA_MIN,
|
||||
};
|
||||
|
||||
use crate::dto::VersionCategory;
|
||||
|
||||
|
||||
Reference in new issue
Block a user