Files
Unlucky-Launcher/crates/app-iced/src/pages/frame/pieces.rs
T
AskaEth 22e4c423fa 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 各一张,证明三页都吃到同一个 α。
2026-11-02 18:07:32 +08:00

334 lines
12 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.
//! 页面用的**占位控件**:卡片 / 分隔线 / 空态 / 文本按钮 / 图标 / 容器样式。
//!
//! 全部走令牌(颜色、圆角、排版、间距、一像素线),但**没有完整状态机**——
//! 这些是给页面骨架站稳版式用的临时件,M74/M75 的 `components/` 家族合入后由另一条 mission 替换。
//! 每个函数都在文档里点明它对应哪个组件的档位(12b 的参考值)。
use iced::widget::text::LineHeight;
use iced::widget::{button, column, container, row, rule, scrollable, space, svg};
use iced::Theme as IcedTheme;
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
use unlucky_design::color::{Role, StateLayer};
use unlucky_design::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::{IconKind, IconPaint};
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::{TypeRole, TypeStyle};
use crate::icons;
use crate::pages::{Action, Message};
use crate::theme::Ui;
/// 空态图标块边长(12b 15.1:96×96,圆角 `Large`)。
pub(super) const EMPTY_ICON_BLOCK: f32 = 96.0;
/// 空态字形渲染尺寸(12b 15.1:图标 32~48 `[估]`,取 40)。
pub(super) const EMPTY_ICON_GLYPH: f32 = 40.0;
/// 滚动条轨宽(视觉复审 P2-2:原来用 iced 默认的 10px,压过了列表内容)。
///
/// 取 6:细到不抢视线,又还在命中/可见的下限之上;滑块做成 full 圆角药丸(半径 3)。
pub const SCROLLBAR_WIDTH: f32 = 6.0;
/// 滑块离卡片内边的留白(2px,避免贴边)。
pub const SCROLLBAR_MARGIN: f32 = 2.0;
/// 描边卡片(占位:M75 的 `components` 会提供真正的 `OutlinedCard`)。
pub fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let spec = Component::OutlinedCard.spec();
container(content)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: ui.outlined(spec.shape),
..container::Style::default()
})
.into()
}
/// 一像素分隔线(`OutlineVariant`,厚度取令牌)。
pub fn divider<'a>(ui: &'a Ui) -> Element<'a, Message> {
rule::horizontal::<IcedTheme>(ui.metrics().divider_thickness)
.style(move |_theme| rule::Style {
color: ui.color(Role::OutlineVariant),
radius: ui.radius_dp(ShapeScale::None),
fill_mode: rule::FillMode::Full,
snap: true,
})
.into()
}
/// 空态(12b §15):图标块 96 + 24 标题 + 12 副文案 + ≤3 个快捷入口,**整块垂直居中**。
pub fn empty_state<'a>(
ui: &'a Ui,
icon: IconKind,
title: &'a str,
body: &'a str,
actions: Vec<Element<'a, Message>>,
) -> Element<'a, Message> {
let spec = Component::EmptyState.spec();
let block = container(glyph(
icon,
ui.color(Role::OnSurfaceVariant),
EMPTY_ICON_GLYPH,
))
.width(EMPTY_ICON_BLOCK)
.height(EMPTY_ICON_BLOCK)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: Border {
radius: ui.radius_dp(spec.shape),
..Border::default()
},
..container::Style::default()
});
let mut stack = column![
block,
text_role_centered(ui, spec.text, false, title, ui.color(Role::OnSurface)),
text_role_centered(
ui,
spec.support.unwrap_or(TypeRole::BodySmall),
false,
body,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X3))
.align_x(Alignment::Center);
if !actions.is_empty() {
stack = stack.push(
row(actions)
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
);
}
container(stack)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
}
/// 文本按钮占位(TextButton 档:透明底、`OnSurfaceVariant`、40 高、full 圆角)。
///
/// 只画静息、hover、pressed 三态(focus/disabled 归 M75 的 `interaction.rs`);
/// 消息是页面自己的 [`Message::Action`],与 app 的 `Message` 完全解耦。
pub fn text_action<'a>(ui: &'a Ui, label: &'a str, action: Action) -> Element<'a, Message> {
let spec = Component::TextButton.spec();
let height = ui.metrics().control_height;
let content = text_role(
ui,
spec.text,
false,
label,
ui.color(Role::OnSurfaceVariant),
);
button(container(content).align_y(Alignment::Center))
.height(height)
.padding(Padding::from([0.0, ui.space(Space::X4)]))
.style(move |_theme: &IcedTheme, status| {
let background = match status {
button::Status::Hovered => Some(Background::Color(ui.state_over(
StateLayer::Hover,
Role::OnSurface,
Role::Surface,
))),
button::Status::Pressed => Some(Background::Color(ui.state_over(
StateLayer::Pressed,
Role::OnSurface,
Role::Surface,
))),
button::Status::Active | button::Status::Disabled => None,
};
button::Style {
background,
text_color: ui.color(Role::OnSurfaceVariant),
border: Border {
radius: ui.radius(ShapeScale::Full, height),
..Border::default()
},
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Action(action))
.into()
}
/// 令牌排版档 → 文字元素(字号 / 行高 / 字重全来自令牌,不写字面量)。
pub fn text_role<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
color: Color,
) -> Element<'a, Message> {
text_widget(ui, role, emphasized, content, color).into()
}
/// 同 [`text_role`],但文案是**拥有的**字符串(计数、时间戳这类运行时格式化出来的短串)。
///
/// 为什么需要它:文字控件**借用**它的内容(`&'a str`),所以 `format!` 出来的临时串不能直接进树;
/// iced 的 `text()` 接受 `String`(`IntoFragment` 为 `String` 实现了 owned 分支),这里把
/// 「令牌化 + owned 内容」两件事合成一处,页面就不必为了一个计数往视图模型里塞字段。
pub fn text_role_owned<'a>(
ui: &Ui,
role: TypeRole,
emphasized: bool,
content: String,
color: Color,
) -> Element<'a, Message> {
let style: TypeStyle = if emphasized {
ui.emphasized(role)
} else {
ui.style(role)
};
iced::widget::text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
.into()
}
/// 同 [`text_role`],但**文字自身居中**(空态的标题与副文案:图标块是居中锚点)。
pub fn text_role_centered<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
color: Color,
) -> Element<'a, Message> {
text_widget(ui, role, emphasized, content, color)
.align_x(iced::widget::text::Alignment::Center)
.into()
}
fn text_widget<'a>(
ui: &'a Ui,
role: TypeRole,
emphasized: bool,
content: &'a str,
color: Color,
) -> iced::widget::Text<'a> {
let style: TypeStyle = if emphasized {
ui.emphasized(role)
} else {
ui.style(role)
};
iced::widget::text(content)
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(color)
}
/// 任意尺寸的令牌图标(`icons::RENDER_PX` 只有 24 一档;空态图标要 40)。
///
/// ## 图标语汇(视觉复审 P2-3)
///
/// 令牌里 **`Play` / `Stop` / `More` 是填充字形**,其余全是描边。页面骨架里**只用描边图标**:
/// 同屏把填充字形和描边字形混在一起,线宽语汇会打架(复审的原话是「Play 是填充字形,
/// 同屏其它图标是描边」)。真要做「主操作允许实心图标」这条例外,应当由组件线在
/// `components/` 里统一定义并只保留一处,而不是散落在页面里——所以这里干脆一个都不放。
/// 守门:`tests/no_hardcoded_tokens.rs` 的 `pages_use_only_stroked_icons`。
///
/// 描边宽度仍按令牌的 `Icon::stroke_width(render_px)` 折算,所以视觉线宽在任何尺寸下都是 1.5。
pub fn glyph(kind: IconKind, color: Color, render_px: f32) -> Element<'static, Message> {
let icon = kind.icon();
let paint = match icon.paint {
IconPaint::Stroke => format!(
"fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \
stroke-linecap=\"round\" stroke-linejoin=\"round\"",
icon.stroke_width(render_px)
),
IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(),
};
let source = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" \
viewBox=\"0 0 24 24\"><path d=\"{1}\" {paint}/></svg>",
render_px, icon.path
);
svg(svg::Handle::from_memory(source.into_bytes()))
.width(render_px)
.height(render_px)
.style(move |_theme, _status| svg::Style { color: Some(color) })
.into()
}
/// 固定宽 × 固定高的图标容器(Q9:按钮给的 Fill 约束会撑大字形,必须中间夹一层)。
pub fn glyph_box(kind: IconKind, color: Color, px: f32) -> Element<'static, Message> {
container(glyph(kind, color, px))
.width(px)
.height(px)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
}
/// 竖直留白(布局拼装用;值来自令牌栅格)。
pub fn gap<'a>(px: f32) -> Element<'a, Message> {
space::vertical().height(px).into()
}
/// 纯色容器样式(页面底色 / 留白带)。
pub fn filled(color: Color) -> container::Style {
container::Style {
background: Some(Background::Color(color)),
..container::Style::default()
}
}
/// 滚动条配色(令牌角色):轨道透明、滑块 `OutlineVariant`(低对比,弱于正文),
/// 轨宽 `SCROLLBAR_WIDTH`(6px)+ `SCROLLBAR_MARGIN`(2px 内缩)——视觉 r1 P2-2 的修法。
///
/// 只在**独立滚动区**上挂(实例页列表列),不许挂到整页上——整页滚动会把页头一起滚走。
pub fn scroll_style(ui: &Ui) -> impl Fn(&IcedTheme, scrollable::Status) -> scrollable::Style + '_ {
let rail = scrollable::Rail {
background: None,
border: Border::default(),
scroller: scrollable::Scroller {
// 低对比:用 `OutlineVariant`(比 `Outline` 弱一档的容器描边角色)而不是 `Outline`,
// 让滑块明显弱于正文;纯色不透明,CPU/GPU 两档一致。
background: Background::Color(ui.color(Role::OutlineVariant)),
border: Border {
radius: ui.radius(ShapeScale::Full, SCROLLBAR_WIDTH),
..Border::default()
},
},
};
move |_theme, _status| scrollable::Style {
container: container::Style::default(),
vertical_rail: rail,
horizontal_rail: rail,
gap: None,
auto_scroll: scrollable::AutoScroll {
background: Background::Color(ui.color(Role::SurfaceContainerHigh)),
border: Border::default(),
shadow: Shadow::default(),
icon: ui.color(Role::OnSurfaceVariant),
},
}
}
/// `crate::icons::RENDER_PX` 的再导出(页面里 24px 图标走这个常量,不写字面量)。
pub const ICON_PX: f32 = icons::RENDER_PX;
/// 小号图标(行尾 chevron 之类的辅助指示,16px)。
pub const ICON_SMALL_PX: f32 = 16.0;