复审前补齐两处(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 各一张,证明三页都吃到同一个 α。
334 lines
12 KiB
Rust
334 lines
12 KiB
Rust
//! 页面用的**占位控件**:卡片 / 分隔线 / 空态 / 文本按钮 / 图标 / 容器样式。
|
||
//!
|
||
//! 全部走令牌(颜色、圆角、排版、间距、一像素线),但**没有完整状态机**——
|
||
//! 这些是给页面骨架站稳版式用的临时件,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;
|