Files
Unlucky-Launcher/crates/app-iced/src/pages/frame.rs
T
AskaEth c495fccd6c fix(app-iced): 视觉 P2-2/3 滚动条变细变淡 + 图标语汇统一成描边
视觉复审两条:

- **P2-2 滚动条偏重**:原来直接用 iced 默认 10px 轨 + `Role::Outline`(那份灰绿在浅色下压过列表内容)。
  改 **6px 轨 + 2px 外边距 + `Role::OutlineVariant` 滑块**(比 `Outline` 弱一档的容器描边角色),
  圆角由 full 按 6px 折算成 3。功能不变(仍是列表列独立滚动、仍只有这一条 scrollable)。
- **P2-3 图标线宽语汇不一致**:设置页「Java」分组用的是令牌里的 `Play`——它是**填充**字形,
  同屏其它图标都是描边。改成描边的 `IconKind::Folder`(Java 分组讲的是运行时安装位置,语义也对),
  并在 `pieces.rs` 写明规矩:页面骨架**只用描边图标**,将来若要「主操作允许实心图标」,
  由 `components/` 统一定义并只留一处。守门见上一条 commit 的 `pages_use_only_stroked_icons`。
2026-11-02 00:39:58 +08:00

190 lines
6.1 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.
//! 页面骨架:**页头**(标题 + 副标题 + 可选操作区)+ **内容区**(列宽由 [`super::layout`] 决定)
//! + **空态位**。
//!
//! ## 边界
//!
//! 这里只放「页面级」的东西:页头、页边距、内容列、两列骨架、空态位的摆放。
//! 具体控件(卡片 / 分隔线 / 文本按钮 / 图标)在 [`pieces`],它们是**占位实现**
//! (同一份令牌,没有完整状态机),等 M75 的 `components/` 家族合入后由另一条 mission 换掉。
//!
//! ## 判据落点(`research/12b`)
//!
//! | 部件 | 参考 | 落点 |
//! |---|---|---|
//! | 页头 | 16.1 AppBar 高 56(登记偏离官方 64)/ 两行 84 | `LayoutMetrics::header_height` |
//! | 页头标题 | 16.4 `TitleLarge 22/28` | `Component::TopAppBar.spec().text` |
//! | 页头副标题 | 16.4 `LabelMedium 12/16` `OnSurfaceVariant` | 同上 `.support` |
//! | 空态 | 15.1 图标块 96×96 / `Large(16)`;15.2 标题 `HeadlineSmall 24`;15.3 副文案 `BodySmall 12` | `Component::EmptyState` |
mod pieces;
#[cfg(test)]
mod tests;
pub use pieces::{
card, divider, empty_state, filled, gap, glyph, glyph_box, scroll_style, text_action,
text_role, text_role_centered, ICON_PX, ICON_SMALL_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH,
};
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::component_map::Component;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::layout::{self, FrameMetrics};
use super::Message;
/// 页头操作区之间的间隔(`Space::X2`)。
const HEADER_ACTION_GAP: f32 = Space::X2.dp();
/// 页头内容。
pub struct Header<'a> {
/// 标题(`TitleLarge`)。
pub title: &'a str,
/// 副标题(`LabelMedium`,给了页头就是两行高)。
pub subtitle: Option<&'a str>,
/// 右侧操作区(text button 占位,数量 ≤3)。
pub actions: Vec<Element<'a, Message>>,
}
impl<'a> Header<'a> {
/// 单行页头。
pub fn new(title: &'a str) -> Self {
Self {
title,
subtitle: None,
actions: Vec::new(),
}
}
/// 加副标题(页头自动变两行高)。
#[must_use]
pub fn subtitle(mut self, subtitle: &'a str) -> Self {
self.subtitle = Some(subtitle);
self
}
/// 追加一个右侧操作。
#[must_use]
pub fn action(mut self, action: Element<'a, Message>) -> Self {
self.actions.push(action);
self
}
}
/// 一整页:页头 + 内容区(页边距已由布局系统决定)。
pub fn page<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
header: Header<'a>,
body: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
let bar = header_bar(ui, metrics, header);
let content = container(body)
.width(Length::Fill)
.height(Length::Fill)
.padding(metrics.gutter);
column![bar, content]
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Element<'a, Message> {
let spec = Component::TopAppBar.spec();
// 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant
let mut titles = column![text_role(
ui,
spec.text,
true,
header.title,
ui.color(Role::OnSurface)
)]
.spacing(ui.space(Space::X1));
if let Some(subtitle) = header.subtitle {
titles = titles.push(text_role(
ui,
spec.support.unwrap_or(TypeRole::LabelMedium),
false,
subtitle,
ui.color(Role::OnSurfaceVariant),
));
}
let actions = row(header.actions)
.spacing(HEADER_ACTION_GAP)
.align_y(Alignment::Center);
container(
row![titles.width(Length::Fill), actions]
.width(Length::Fill)
.align_y(Alignment::Center),
)
.width(Length::Fill)
.height(ui.metrics().header_height(header.subtitle.is_some()))
.align_y(Alignment::Center)
.padding(Padding::from([0.0, metrics.gutter]))
.into()
}
/// 单列版面的正文列:宽 = 行宽上限内的实际列宽,**水平居中**。
pub fn single_column<'a>(
metrics: FrameMetrics,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
centered(metrics.column, content)
}
/// 两列版面:左列 `secondary`(独立滚动区由调用方自己加 `scrollable`)+ 右列 `main`。
///
/// 两列都**显式**取布局系统给的宽度,不吃 Fill:列表列必须固定 336,
/// 详情列必须窄于可用宽度,否则又回到「全宽单卡」。
pub fn panes<'a>(
metrics: FrameMetrics,
secondary: impl Into<Element<'a, Message>>,
main: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
let width = metrics.secondary.unwrap_or(0.0);
row![
container(secondary)
.width(Length::Fixed(width))
.height(Length::Fill),
container(main)
.width(Length::Fixed(metrics.main_pane))
.height(Length::Fill),
]
.spacing(layout::PANES_GAP)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
/// 把内容塞进固定宽度并水平居中(`align_x` 不会覆盖子元素的宽度,见 Q8)。
pub fn centered<'a>(width: f32, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(container(content).width(Length::Fixed(width)))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Start)
.into()
}
/// 与 [`centered`] 同宽,但**贴左**:列表右侧的详情列要贴着自己的 pane 左边,不再二次居中。
pub fn left_aligned<'a>(
width: f32,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
container(container(content).width(Length::Fixed(width)))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Start)
.align_y(Alignment::Start)
.into()
}