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

@@ -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