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

@@ -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()
}