feat(app-iced): M93 —— 外观接线:整窗 α / 面板 α / 背景图 / 配色来源
把 appearance.* 那批键接到窗口与像素上(M91 只落了键与判据,零调用方)。
整窗 α(D34/D36)
- `ensure_layered(hwnd)` 落在唯一 unsafe 文件 `platform/win32/transparency.rs`
(`WS_EX_LAYERED` 只有 Get/SetWindowLongPtrW 能加,且 CI 的 unsafe-scope 是集合相等);
`app/boot.rs` 只负责开窗,句柄到手后由 `Message::WindowHandle` → `window_alpha::dispatch`
先补样式再下发。本机实测 `layered=ok outcome=Applied { byte: 205 }`(α=0.8)。
- 三条入口都会重算并下发:启动(句柄到达)、设置页改动、配置文件热切。
- `layered` 日志有三态:`n/a`(α≥1,压根不需要)/ `ok` / `FAILED`,避免把「没用到」写成假红。
面板 α 与 CPU 档(D36)
- 面板 α 独立于整窗 α:只作用到外壳层(根容器 / 标题栏 / 导航轨)与外观卡片;
CPU 档强制 1.0、玻璃强制关(`pages/appearance/alpha.rs` 的真值表 + 单测)。
- 色卡档继续走令牌 baseline,只有取色档(①②级)才换 `Ui` 的 scheme(`Ui::from_scheme`)。
背景图(D38)
- 读盘解码(png)→ cover/contain/tile 三种填充几何 → `bg_dim` 向底色退 → 36dp 网格马赛克;
iced 的 `image` widget 需要 `iced/image` feature(Cargo.toml 不在 scope),故为已登记降级。
- 读盘失败静默回落 + 一条可读诊断(设置页错误行 + 日志),配色经 `Provenance` 回落色卡。
设置页「外观」分组
- 整窗透明度滑块(下限 0.08)/ 面板透明度(CPU 档禁用并写明 55%→100% 的降级)/
背景图开关与路径 / 填充三档 / 暗化 / 配色来源三档;改完即时生效并写回:
bridge 覆盖的 5 个键走 `SettingsBridge::persist`,`window_opacity` / `palette_source`
走 `AppCommand::SetSetting`(每个键只有一个写者)。
- `SettingsBridge::load_base()`(presenter)让 app-iced 不需要直接依赖 core 就能读回
`window_opacity` / `palette_source`。
取证
- `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 只影响本次进程(语义同 ULL_TICK_TRACE),
截图通道不读用户配置(D30)。
This commit is contained in:
1 parent
eae7ae0ff4
commit
b8ab46662c
24 files changed
+3119
-48
No files matched your search
@@ -0,0 +1,357 @@
|
||||
//! 背景图:**读盘解码 → 填充几何 → 暗化 → 马赛克取样**。纯函数(除 [`load`] 一次 IO)。
|
||||
//!
|
||||
//! ## 三种填充(`appearance.bg_fill`,core 的 [`BgFill`])
|
||||
//!
|
||||
//! | 档 | 缩放 | 位置 | 露底色 |
|
||||
//! |---|---|---|---|
|
||||
//! | `cover` | `max(vw/iw, vh/ih)`(铺满、裁掉溢出) | 居中 | 否 |
|
||||
//! | `contain` | `min(vw/iw, vh/ih)`(完整显示) | 居中 | **是**(上下或左右留底色) |
|
||||
//! | `tile` | `1`(原始尺寸平铺) | 从视口左上角起重复 | 否 |
|
||||
//!
|
||||
//! 几何是**纯函数**([`placement`] / [`source_point`]),因此三种档的差异可以被数值断言,
|
||||
//! 不必靠肉眼比对截图。
|
||||
//!
|
||||
//! ## 暗化(`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 个方块)。
|
||||
|
||||
use unlucky_presenter::prefs::BgFill;
|
||||
|
||||
/// 暗化上限(与 core 的 `BG_DIM_MAX` 同值 0.95):再高背景图基本看不见,等于关掉。
|
||||
pub const BG_DIM_MAX: f32 = 0.95;
|
||||
|
||||
/// 解码后保留的最大边长:取样与取色都不需要分辨率,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;
|
||||
|
||||
/// 解码失败的原因(人话在 [`LoadError::describe`])。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LoadError {
|
||||
/// 读文件失败:不存在 / 没权限 / 被占用。
|
||||
Io(String),
|
||||
/// 解码失败:格式不认识(本 build 只带 png 编解码器)/ 文件截断 / 不是图片。
|
||||
Undecodable(String),
|
||||
/// 图片头里的尺寸超限。
|
||||
TooLarge {
|
||||
/// 宽。
|
||||
width: u32,
|
||||
/// 高。
|
||||
height: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl LoadError {
|
||||
/// 可读诊断(设置页提示行与日志共用同一句)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
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)"
|
||||
),
|
||||
Self::TooLarge { width, height } => format!(
|
||||
"Background image is too large to decode: {width}×{height} (limit {MAX_SOURCE_EDGE} px per edge)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一张解码好的背景图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Image {
|
||||
/// **原图**尺寸(像素):填充几何按它算(`tile` = 原始尺寸平铺)。
|
||||
pub source: (u32, u32),
|
||||
/// 缩略图宽(≤ [`MAX_EDGE`])。
|
||||
pub width: u32,
|
||||
/// 缩略图高(≤ [`MAX_EDGE`])。
|
||||
pub height: u32,
|
||||
/// 缩略图 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 {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读盘 + 解码 + 缩到 [`MAX_EDGE`] 内。
|
||||
///
|
||||
/// # Errors
|
||||
/// 见 [`LoadError`]:读不到 / 解不开 / 头部尺寸超限。**不 return 默认色**——回落的决定在
|
||||
/// [`super::palette`](它会往 `Provenance` 里留原因),这一层只管如实报告失败。
|
||||
pub fn load(path: &str) -> Result<Image, LoadError> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(LoadError::Io("no path configured".to_owned()));
|
||||
}
|
||||
// 第一趟只读图头:先按尺寸拒掉压缩炸弹,再真解码。
|
||||
let (source_w, source_h) = header_size(trimmed)?;
|
||||
if source_w > MAX_SOURCE_EDGE || source_h > MAX_SOURCE_EDGE {
|
||||
return Err(LoadError::TooLarge {
|
||||
width: source_w,
|
||||
height: source_h,
|
||||
});
|
||||
}
|
||||
|
||||
let decoded = open(trimmed)?
|
||||
.decode()
|
||||
.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);
|
||||
let target_w = ((f64::from(width) * scale).round() as u32).max(1);
|
||||
let target_h = ((f64::from(height) * scale).round() as u32).max(1);
|
||||
let thumb = image::imageops::resize(
|
||||
&decoded,
|
||||
target_w,
|
||||
target_h,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
|
||||
Ok(Image::from_rgba(
|
||||
(source_w, source_h),
|
||||
target_w,
|
||||
target_h,
|
||||
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
|
||||
.with_guessed_format()
|
||||
.map_err(|error| LoadError::Undecodable(error.to_string()))
|
||||
}
|
||||
|
||||
fn header_size(path: &str) -> Result<(u32, u32), LoadError> {
|
||||
let reader = open(path)?;
|
||||
if reader.format().is_none() {
|
||||
return Err(LoadError::Undecodable(
|
||||
"unknown image format (magic bytes not recognised)".to_owned(),
|
||||
));
|
||||
}
|
||||
reader
|
||||
.into_dimensions()
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按填充档算一次几何(纯函数)。
|
||||
#[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 {
|
||||
if dim.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
dim.clamp(0.0, BG_DIM_MAX)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次渲染要画的马赛克:按行主序铺的格子色(`None` = 露底色)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Mosaic {
|
||||
/// 列数。
|
||||
pub cols: u32,
|
||||
/// 行数。
|
||||
pub rows: u32,
|
||||
/// 每格颜色(`rows × cols`,行主序)。
|
||||
pub cells: Vec<Option<[u8; 3]>>,
|
||||
}
|
||||
|
||||
impl Mosaic {
|
||||
/// 第 `row` 行第 `col` 列的颜色(越界给 `None`)。
|
||||
#[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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口 → 网格尺寸(格边长 ≈ [`CELL_PX`],列/行各有上限)。
|
||||
#[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)
|
||||
}
|
||||
|
||||
/// 算一整张马赛克(纯函数:同一图 + 同一视口 + 同一底色 → 同一结果)。
|
||||
#[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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Mosaic { cols, rows, cells }
|
||||
}
|
||||
|
||||
fn div_ceil(value: f32, step: f32) -> u32 {
|
||||
let count = (value / step).ceil();
|
||||
if count.is_finite() && count >= 1.0 {
|
||||
count as u32
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user