Files
Unlucky-Launcher/crates/app-iced/src/pages/appearance/mod.rs
T
AskaEth d517f28140
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Unlucky Launcher v0.2.1
Single-commit source snapshot of the v0.2.1 release.
2026-11-06 12:35:56 +08:00

220 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 外观接线(M93 / APPEARWIRE):**键 → 像素**
//!
//! 本模块把 `appearance.*` 那批键接到真正的窗口与真正的像素上,并把「哪里是判据、哪里是观感」
//! 分开写清楚。底座(M91)已经给了键、钳位判据与唯一的不安全落点;这里补的是**调用方**。
//!
//! ## 两层透明度(D34 / D36)
//!
//! | 层 | 作用范围 | 实现 | CPU 档 | 下限 |
//! |---|---|---|---|---|
//! | 整窗 α | 整扇窗口(标题栏 + 导航轨 + 内容 + 卡片) | `SetLayeredWindowAttributes(LWA_ALPHA)`,DWM 常数乘法 | **允许** | 0.08(α=0 = 整窗穿透,拒绝) |
//! | 面板 α | 只有面板/卡片表面这一层(背景图能透出来) | 容器色的 alpha([`section`] / `shell`) | **强制 1.0** | 0.4 |
//!
//! 真值表在 [`alpha`] 的模块文档里(三层 × 三个后端,含「钳位还是拒绝」)。
//!
//! ## 模块分工
//!
//! | 文件 | 是什么 | 有没有 IO |
//! |---|---|---|
//! | [`alpha`] | 两层 α 的钳位链、CPU 档降级、滑块映射、下发字节 | 无(纯函数) |
//! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) |
//! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) |
//! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` |
//! | `section` | 设置页「外观」分组(真控件) | 无 |
//! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 |
//!
//! ## 偏离登记(D30 要求逐条写清)
//!
//! | # | 偏离 | 为什么 | 什么时候收口 |
//! |---|---|---|---|
//! | 1 | ~~背景图按 36dp 马赛克画~~ → **r2 已销**:iced 的 `image-without-codecs` 打开,真图直出(`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~~ → **r2 已销**:`image` 补了 `jpeg`/`bmp`,单测与截图都用 `.jpg` 举证 | 同上 | ✅ |
//! | 7 | `bg_dim` 是「往**面板底色**退」,不是「叠黑」 | 浅色主题下叠黑会把亮图压成暗块,深色正文反而更读不清;向底色退在两种主题下都单调变好(见 [`background::dim_alpha`]) | 若将来为了「夜间压暗」需要真叠黑,加一档独立键(不要复用这个) |
//! | 8 | ~~页头文字压在画布上~~ → **r2 已销**:`frame::header_bar` 加了面板底色 | M93 r2 扩了 `pages/frame.rs` 的 scope | ✅ |
//!
//! ## 取证
//!
//! 每一处改动都打一行 [`state::State::log_line`]:`[appearance] window_alpha=… panel_alpha=…
//! byte=… corner=…`;圆角另有一条独立管线,行形如
//! `[corner] <ctx> corner=policy=system source=dwm os=10.0.26100 excluded_reason=none
//! radius=system pref=2 readback=? read_hr=0x80070057 frame=overlapped layered=on
//! suppressed_by=window_alpha`(真进程实跑的两条见 `DevDocs/artifacts/m116/README.md`;
//! `frame=` 恒为 `overlapped`/`other`,**不会是 `popup`**)
//! (见 `app::update::appearance` 与 `platform::win32::corner::format_line`)。数值是**下发前**的
//! 字节/属性值(不是观感),因为在 RDP + 虚拟显示适配器上「看上去多透明 / 圆不圆」不可信。
//! 截图矩阵用 `ULL_APPEARANCE_*` 环境变量灌值(见 [`state`] 的常量)。
use iced::widget::container;
use iced::{Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::pages::frame;
use crate::pages::Message;
use crate::theme::Ui;
pub mod alpha;
pub mod background;
mod palette;
mod picture;
mod section;
mod state;
/// 状态提示的 **tonal notice**(`section.rs` 的 400 行硬顶把它放在模块根)。
///
/// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。
/// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开
/// (不靠色相、不新增令牌)。调用点见 [`section::section`] 的圆角一节。
fn notice<'a>(ui: &'a Ui, text: &str) -> Element<'a, Message> {
let background = ui.color(Role::SurfaceContainerHighest);
let tone = ui.color(Role::OnSurfaceVariant);
let body = frame::text_role_owned(ui, TypeRole::BodySmall, true, text.to_owned(), tone);
let pad = ui.space(Space::X2);
let radius = ui.radius_dp(ShapeScale::Small);
container(body)
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X1), pad]))
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: Border {
radius,
..Border::default()
},
..container::Style::default()
})
.into()
}
pub use picture::layer;
pub use section::section;
pub use state::State;
/// 设置页「外观」分组在左列里的下标(0 = General,1 = Appearance …)。
pub const APPEARANCE_GROUP: usize = 1;
/// 设置页「外观」分组的显示名。
pub const GROUP_LABEL: &str = "Appearance";
/// 一次外观改动(设置页 → `App`)。
///
/// 值都是**用户请求值**(未钳位):钳位与降级只发生在 [`alpha`],这样「用户设了 0.5、CPU 档实际
/// 1.0」这种差别在状态里能看出来,日志也就不会把请求值写成生效值。
#[derive(Debug, Clone, PartialEq)]
pub enum Change {
/// 整窗透明度(请求值,0.08~1.0)。
WindowOpacity(f32),
/// 面板透明度(请求值,0.4~1.0)。
PanelAlpha(f32),
/// 背景图开关。
BgEnabled(bool),
/// 背景图路径(空串 = 清掉)。
BgImage(String),
/// 填充档。
BgFill(unlucky_presenter::prefs::BgFill),
/// 暗化强度(0~0.95)。
BgDim(f32),
/// 配色来源档。
PaletteSource(unlucky_presenter::prefs::PaletteSource),
/// 窗口圆角档位(M116:跟随系统 / 直角)。
Corner(unlucky_presenter::prefs::WindowCorner),
/// 设置页切分组(左列点选)。
SelectGroup(usize),
}
impl Change {
/// 这条改动对应哪个 schema 键(写回配置用;`SelectGroup` 不落盘)。
#[must_use]
pub fn key(&self) -> Option<&'static str> {
Some(match self {
Self::WindowOpacity(_) => unlucky_presenter::prefs::keys::WINDOW_OPACITY,
Self::PanelAlpha(_) => unlucky_presenter::prefs::keys::PANEL_ALPHA,
Self::BgEnabled(_) => unlucky_presenter::prefs::keys::BG_IMAGE_ENABLED,
Self::BgImage(_) => unlucky_presenter::prefs::keys::BG_IMAGE,
Self::BgFill(_) => unlucky_presenter::prefs::keys::BG_FILL,
Self::BgDim(_) => unlucky_presenter::prefs::keys::BG_DIM,
Self::PaletteSource(_) => unlucky_presenter::prefs::keys::PALETTE_SOURCE,
Self::Corner(_) => unlucky_presenter::prefs::keys::WINDOW_CORNER,
Self::SelectGroup(_) => return None,
})
}
/// 这条改动的落盘字符串形态(与 `schema` 的取值词表逐字一致)。
///
/// `None` = 不落盘(分组选择)。数字一律给 `f64` 的十进制字面量(`store.set` 按 schema 类型转)。
#[must_use]
pub fn value(&self) -> Option<String> {
Some(match self {
Self::WindowOpacity(alpha) => format!("{alpha}"),
Self::PanelAlpha(alpha) => format!("{alpha}"),
Self::BgEnabled(enabled) => enabled.to_string(),
Self::BgImage(path) => path.clone(),
Self::BgFill(fill) => fill.as_str().to_owned(),
Self::BgDim(dim) => format!("{dim}"),
Self::PaletteSource(source) => source.as_str().to_owned(),
Self::Corner(corner) => corner.as_str().to_owned(),
Self::SelectGroup(_) => return None,
})
}
}
#[cfg(test)]
mod tests {
use super::{Change, APPEARANCE_GROUP};
use unlucky_presenter::prefs::{keys, BgFill, PaletteSource};
/// 抓得住:每条改动都点名到自己那个 schema 键(写错键 = 静默写到别的配置项上)。
/// 抓不住:这些键是否真的在 core 的 schema 白名单里(那由 presenter/core 的测试守)。
#[test]
fn every_change_names_its_own_schema_key() {
assert_eq!(APPEARANCE_GROUP, 1, "左列第 1 项是 Appearance");
let cases = [
(Change::WindowOpacity(0.8), keys::WINDOW_OPACITY),
(Change::PanelAlpha(0.6), keys::PANEL_ALPHA),
(Change::BgEnabled(true), keys::BG_IMAGE_ENABLED),
(Change::BgImage("D:/x.png".to_owned()), keys::BG_IMAGE),
(Change::BgFill(BgFill::Tile), keys::BG_FILL),
(Change::BgDim(0.5), keys::BG_DIM),
(
Change::PaletteSource(PaletteSource::Wallpaper),
keys::PALETTE_SOURCE,
),
(
Change::Corner(unlucky_presenter::prefs::WindowCorner::Square),
keys::WINDOW_CORNER,
),
];
for (change, key) in cases {
assert_eq!(change.key(), Some(key), "{change:?}");
assert!(change.value().is_some());
}
assert_eq!(Change::SelectGroup(2).key(), None, "切分组不落盘");
assert_eq!(Change::SelectGroup(2).value(), None);
}
/// 抓得住:落盘字符串必须是 schema 认识的词表(枚举档写错就整条配置读回默认值)。
/// 抓不住:数值是否在范围内(钳位在 core 侧,越界写入会被 schema 拒绝)。
#[test]
fn persisted_values_use_the_schema_vocabulary() {
assert_eq!(
Change::BgFill(BgFill::Contain).value().as_deref(),
Some("contain")
);
assert_eq!(
Change::PaletteSource(PaletteSource::BackgroundImage)
.value()
.as_deref(),
Some("background-image")
);
assert_eq!(Change::BgEnabled(false).value().as_deref(), Some("false"));
assert_eq!(Change::WindowOpacity(0.8).value().as_deref(), Some("0.8"));
}
}