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:
AskaEth committed 2026-11-02 18:07:31 +08:00
1 parent eae7ae0ff4
commit b8ab46662c
24 files changed
+3119 -48

No files matched your search

@@ -0,0 +1,400 @@
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
//!
//! ## 控件与键的对应
//!
//! | 控件 | 键 | 改动消息 |
//! |---|---|---|
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
//!
//! 文案一律英文基线(i18n 是别的 mission;本文件不拼中文、不 import 尚不存在的 crate)。
//!
//! ## 为什么「值」与「生效值」分开显示
//!
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
use iced::widget::{button, column, container, row};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_presenter::prefs::{BgFill, PaletteSource};
use crate::app::components::inputs::{slider, text_field, Field, Slider};
use crate::pages::{frame, Message};
use crate::theme::Ui;
use super::alpha::{
panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction,
WINDOW_ALPHA_FLOOR,
};
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);
// ① 整窗透明度
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
rows = rows.push(slider_row(
ui,
"Window transparency",
window_percent,
format!(
"Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.",
WINDOW_ALPHA_FLOOR
),
state.window_opacity < 1.0,
slider(
Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| {
Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction)))
}),
),
));
// ② 面板透明度(CPU 档强制 1.0)
let forced = state.panel_is_forced();
let panel_percent = if forced {
format!("100% (forced from {:.2})", state.panel_alpha)
} else {
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
};
let panel_hint = if forced {
"Software renderer: panels stay fully opaque (window transparency still works)."
} else {
"Only the panel/card layer. Floor 0.40; independent of window transparency."
};
let panel_control = if forced {
slider(Slider::new(ui, panel_fraction(1.0)))
} else {
slider(
Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| {
Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction)))
}),
)
};
rows = rows.push(slider_row(
ui,
"Panel transparency",
panel_percent,
panel_hint.to_owned(),
!forced,
panel_control,
));
// ③ 背景图开关
rows = rows.push(choice_row(
ui,
"Background image",
vec![
choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)),
choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)),
],
));
// ④ 路径(文本框:受控,输入即生效)
let path_field: Element<'a, Message> = text_field(
ui,
Field::new(&state.bg_path)
.label("Image path")
.placeholder("D:/Pictures/background.png"),
|value| Message::Appearance(Change::BgImage(value)),
);
rows = rows.push(
column![label(ui, "Image path"), path_field]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
);
// ⑤ 填充
rows = rows.push(choice_row(
ui,
"Fill",
vec![
choice(
ui,
"Cover",
state.bg_fill == BgFill::Cover,
Change::BgFill(BgFill::Cover),
),
choice(
ui,
"Contain",
state.bg_fill == BgFill::Contain,
Change::BgFill(BgFill::Contain),
),
choice(
ui,
"Tile",
state.bg_fill == BgFill::Tile,
Change::BgFill(BgFill::Tile),
),
],
));
// ⑥ 暗化
rows = rows.push(slider_row(
ui,
"Dim",
format!("{:.0}%", state.bg_dim * 100.0),
format!(
"Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}."
),
state.bg_dim > 0.0,
slider(
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
),
));
// ⑦ 配色来源
rows = rows.push(choice_row(
ui,
"Colour source",
vec![
choice(
ui,
"Background",
state.palette_source == PaletteSource::BackgroundImage,
Change::PaletteSource(PaletteSource::BackgroundImage),
),
choice(
ui,
"Wallpaper",
state.palette_source == PaletteSource::Wallpaper,
Change::PaletteSource(PaletteSource::Wallpaper),
),
choice(
ui,
"Preset",
state.palette_source == PaletteSource::Preset,
Change::PaletteSource(PaletteSource::Preset),
),
],
));
// ⑧ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**
for note in state.diagnostics() {
rows = rows.push(note_line(ui, note, ui.color(Role::Error)));
}
if let Some(note) = state.backend_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
if let Some(note) = state.palette_note() {
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
}
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)
}
fn card_with_panel_alpha<'a>(
ui: &'a Ui,
state: &State,
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());
container(content)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: ui.outlined(spec.shape),
..container::Style::default()
})
.into()
}
// --------------------------------------------------------------- 行与控件
fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
content,
ui.color(Role::OnSurface),
)
}
fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
}
/// 一行「标签 + 值 + 滑块 + 说明」。
fn slider_row<'a>(
ui: &'a Ui,
title: &'a str,
value: String,
hint: String,
enabled: bool,
control: Element<'a, Message>,
) -> Element<'a, Message> {
let value_color = if enabled {
ui.color(Role::OnSurface)
} else {
ui.color(Role::OnSurfaceVariant)
};
let head = row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
]
.width(Length::Fill)
.align_y(Alignment::Center);
column![
head,
control,
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一行「标签 + 一排互斥按钮」。
fn choice_row<'a>(
ui: &'a Ui,
title: &'a str,
choices: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
row![
container(label(ui, title))
.width(Length::Fill)
.align_y(Alignment::Center),
row(choices)
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.into()
}
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
fn choice<'a>(ui: &'a Ui, text: &'a str, selected: bool, change: Change) -> Element<'a, Message> {
let height = ui.metrics().control_height;
let content_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
button(
container(frame::text_role(
ui,
TypeRole::LabelLarge,
selected,
text,
content_color,
))
.align_y(Alignment::Center),
)
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X3)]))
.style(move |_theme: &IcedTheme, status| {
let background = if selected {
Some(Background::Color(ui.color(Role::SecondaryContainer)))
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
Some(Background::Color(ui.state_over(
unlucky_design::color::StateLayer::Hover,
Role::OnSurface,
Role::Surface,
)))
} else {
None
};
button::Style {
background,
text_color: content_color,
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Appearance(change))
.into()
}