`interaction::disabled` 的四个入口一直收一个「底」,但 8 个调用点全传死 `Role::Surface`:卡片(SurfaceContainer)/ 面板(SurfaceContainerLow)里的禁用控件 把 10%(按钮族)/ 12%(面状控件族)叠在画布色上 —— M223「CR 1.05、形丢失」的根因。 - 新增 `interaction::Base`(画布 / 容器底的一等参数,`From<Role>` 兼容历史调用点); 四个入口改成 `(ui, base, …)`,内部不再出现任何写死的 `Role::Surface`。 - 容器导出自己的 `BASE`:`surfaces::elevated::BASE`(卡)、`inputs::menu::PANEL_BASE` (菜单面板,项的 38% 与状态层都改叠它)、`navigation::drawer::BASE`。 - 控件收 `.base(..)`:Button / icon_on / Field / SearchBar / Slider / Checkbox / Radio / Switch / Chip / ListItem / Tabs;Fab 与 icon() 是画布默认值。 - `feedback/action.rs` 的本地 α 合成删掉,改走 `disabled_content_over_role` 并给真实容器底 (Snackbar `InverseSurface` / Dialog `SurfaceContainerHigh`)。 - 判据:`interaction::disabled::tests`(卡片/面板/画布三档逐值)、 `buttons::tests::base`(Button::base → StyleSpec → style_closure 接线)、 `gallery` 新增 `disabledbase` 段 + `gallery::probe::bases` 精确像素断言 (±10 容差区分不了 1/255 的差,所以自己数逐位相等的像素)。 - 变异自证:assemble 的调用点改回画布底后,上面两条都红(见 artifacts/m234/README.md)。 已知未尽:`crates/app-iced/src/pages/**` 不在 scope 内,页面侧「把控件放进卡片/面板」的 调用点尚未接 `.base(..)`(行为与本 mission 之前一致);`pages/…/java/tests/marker.rs` 有一行被迫更新(入口多了一个实参,语义不变 = 画布底)。
396 lines
15 KiB
Rust
396 lines
15 KiB
Rust
//! `--screen=gallery` 的壳:**视觉验收 harness**(D30 / D39 §5),不是产品页面。
|
||
//!
|
||
//! ## 它是什么、不是什么
|
||
//!
|
||
//! - 是:**状态矩阵的取景框**。每个家族在自己的 `gallery/<family>.rs` 里摆出
|
||
//! 「五变体 × 五态」的矩阵,一张截图就能判完该家族(浅/深各一张 = 10 张证据)。
|
||
//! - 不是:页面。gallery 允许堆叠、不分页、不做空态,**难看不算问题**;
|
||
//! 但**每个组件本身**必须按官方口径做到位。
|
||
//!
|
||
//! ## 并行地基(后续四条线怎么加东西)
|
||
//!
|
||
//! 各家族文件在下面 `mod` 里**全部预先声明**了,每个都有自己的 `section()`:
|
||
//!
|
||
//! ```text
|
||
//! gallery/buttons.rs ← COMPBASE(本 mission)
|
||
//! gallery/selection.rs ← 后续 mission(只改这个文件)
|
||
//! gallery/inputs.rs ← 同上
|
||
//! gallery/feedback.rs ← 同上
|
||
//! gallery/navigation.rs ← 同上
|
||
//! ```
|
||
//!
|
||
//! 加组件 = 只改自己那个文件里的 `section()`,**不用碰本文件**(这是「五条线并行不冲突」的
|
||
//! 具体保证,也是 tower 会亲手验的一条)。
|
||
//!
|
||
//! ## DemoMessage 为什么独立
|
||
//!
|
||
//! 契约 §2:组件不 import `crate::app::Message`。gallery 用自己定义的消息枚举,
|
||
//! 正好当「组件能脱离业务验收」的示范——`app.rs` 只做一次 `.map(Message::Demo)` 就接上了。
|
||
//!
|
||
//! ## 截图命令(D30)
|
||
//!
|
||
//! ```text
|
||
//! unlucky-launcher-iced --software-renderer --screen=gallery \
|
||
//! --screenshot out.png --screenshot-theme=light
|
||
//! ```
|
||
//!
|
||
//! `--screenshot` 不带主题时**默认 Light**(可复现;见 `cli::Options::capture_flavor`)。
|
||
//!
|
||
//! ## 段落过滤:环境变量 `ULL_GALLERY_SECTION`(M76 起)
|
||
//!
|
||
//! **七段**(buttons / sizes / divider / selection / inputs / feedback / navigation)拼成一列、
|
||
//! 外面套 `scrollable`,而截图只截**首屏** `1180×760`。段数一多,后面的家族
|
||
//! 就落到折叠线以下(实测:selection 段从 y≈735 起,只剩标题一行)——那条线的
|
||
//! 「渲染层像素断言」就没有一个像素可量。所以这里加一个**段落选择器**:
|
||
//!
|
||
//! ```text
|
||
//! ULL_GALLERY_SECTION=all # 默认:七段全渲染(行为与加开关之前逐像素一致)
|
||
//! ULL_GALLERY_SECTION=buttons # 只渲染 buttons 段
|
||
//! ULL_GALLERY_SECTION=selection # 只渲染 selection 段(本 mission 的取证与 probe 用它)
|
||
//! ```
|
||
//!
|
||
//! 取值:`all` / `buttons` / `sizes` / `divider` / `selection` / `inputs` / `feedback` / `navigation`。
|
||
//! 语义是「**不构建**没被选中的段」(不是构建后隐藏):`column` 里根本不 push 它,
|
||
//! 于是该家族的 `view` 一行都不跑。未识别的值**回落 `all`** 并在 stderr 打一行提示
|
||
//! (静默回落会让人拿错图还以为拍的是那一段)。
|
||
//!
|
||
//! 为什么用环境变量而不是 `--screen=gallery:<family>`:`cli.rs` 不归组件线(M82 在改它),
|
||
//! 而环境变量既能被测试(`cargo test` 里 `std::env::set_var`)又不用跟别人的 CLI 解析抢文件。
|
||
//! 各家族的 `demo()` 签名**不因此改变**——本文件只决定「push 哪几段」。
|
||
//!
|
||
//! 实际渲染了哪几段会打一行 `[gallery] section=... → 渲染 ...`(首帧一次),
|
||
//! 拿它当出图日志的证据。
|
||
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
|
||
use iced::widget::{column, container, scrollable, text};
|
||
use iced::{Alignment, Background, Element, Length};
|
||
|
||
use unlucky_design::color::Role;
|
||
use unlucky_design::elevation::MaterialLayer;
|
||
use unlucky_design::shape::ShapeScale;
|
||
use unlucky_design::spacing::Space;
|
||
use unlucky_design::typography::TypeRole;
|
||
|
||
use crate::shot::flavor_slug;
|
||
use crate::theme::Ui;
|
||
|
||
use super::divider;
|
||
use super::interaction::Interaction;
|
||
use super::layout;
|
||
|
||
mod buttons;
|
||
mod disabledbase;
|
||
mod feedback;
|
||
mod inputs;
|
||
mod navigation;
|
||
#[cfg(test)]
|
||
mod probe;
|
||
#[cfg(test)]
|
||
mod scan;
|
||
mod selection;
|
||
mod sizes;
|
||
|
||
/// 段落过滤的环境变量名(语义见模块文档「段落过滤」)。
|
||
pub const SECTION_ENV: &str = "ULL_GALLERY_SECTION";
|
||
|
||
/// 段落开关:**构建**哪几段(`false` = 这一段连 `view` 都不跑)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
struct Sections {
|
||
buttons: bool,
|
||
disabledbase: bool,
|
||
sizes: bool,
|
||
divider: bool,
|
||
selection: bool,
|
||
inputs: bool,
|
||
feedback: bool,
|
||
navigation: bool,
|
||
}
|
||
|
||
impl Sections {
|
||
/// 七段全渲染(默认)。
|
||
const ALL: Self = Self {
|
||
buttons: true,
|
||
disabledbase: true,
|
||
sizes: true,
|
||
divider: true,
|
||
selection: true,
|
||
inputs: true,
|
||
feedback: true,
|
||
navigation: true,
|
||
};
|
||
|
||
/// 只有一段。
|
||
fn only(name: &str) -> Option<Self> {
|
||
let mut sections = Self {
|
||
buttons: false,
|
||
disabledbase: false,
|
||
sizes: false,
|
||
divider: false,
|
||
selection: false,
|
||
inputs: false,
|
||
feedback: false,
|
||
navigation: false,
|
||
};
|
||
match name {
|
||
"buttons" => sections.buttons = true,
|
||
"disabledbase" => sections.disabledbase = true,
|
||
"sizes" => sections.sizes = true,
|
||
"divider" => sections.divider = true,
|
||
"selection" => sections.selection = true,
|
||
"inputs" => sections.inputs = true,
|
||
"feedback" => sections.feedback = true,
|
||
"navigation" => sections.navigation = true,
|
||
_ => return None,
|
||
}
|
||
Some(sections)
|
||
}
|
||
|
||
/// 读 `ULL_GALLERY_SECTION`。
|
||
///
|
||
/// 未设置 / 空 / `all`(大小写不敏感)→ 全渲染;**未识别的值 → 回落全渲染 + stderr 一行**。
|
||
fn from_env() -> Self {
|
||
let Ok(raw) = std::env::var(SECTION_ENV) else {
|
||
return Self::ALL;
|
||
};
|
||
let name = raw.trim().to_ascii_lowercase();
|
||
if name.is_empty() || name == "all" {
|
||
return Self::ALL;
|
||
}
|
||
if let Some(sections) = Self::only(&name) {
|
||
return sections;
|
||
}
|
||
eprintln!(
|
||
"[gallery] {SECTION_ENV}={raw:?} 不是已知段(all|buttons|disabledbase|sizes|divider|selection|inputs|feedback|navigation),已回落 all"
|
||
);
|
||
Self::ALL
|
||
}
|
||
|
||
/// 本次渲染的段名(stderr 日志 / 测试断言用)。
|
||
fn names(self) -> Vec<&'static str> {
|
||
let mut names = Vec::new();
|
||
for (on, name) in [
|
||
(self.buttons, "buttons"),
|
||
(self.disabledbase, "disabledbase"),
|
||
(self.sizes, "sizes"),
|
||
(self.divider, "divider"),
|
||
(self.selection, "selection"),
|
||
(self.inputs, "inputs"),
|
||
(self.feedback, "feedback"),
|
||
(self.navigation, "navigation"),
|
||
] {
|
||
if on {
|
||
names.push(name);
|
||
}
|
||
}
|
||
names
|
||
}
|
||
}
|
||
|
||
/// `[gallery] ... 渲染 ...` 只打一次(`view` 每帧都会跑)。
|
||
static SECTION_LOGGED: AtomicBool = AtomicBool::new(false);
|
||
|
||
fn log_sections(sections: Sections) {
|
||
if SECTION_LOGGED.swap(true, Ordering::Relaxed) {
|
||
return;
|
||
}
|
||
let raw = std::env::var(SECTION_ENV).unwrap_or_else(|_| "all".to_owned());
|
||
eprintln!(
|
||
"[gallery] section={raw} → 渲染 {} 段:{}",
|
||
sections.names().len(),
|
||
sections.names().join(", ")
|
||
);
|
||
}
|
||
|
||
/// gallery 自己的消息(与业务 `Message` 解耦,契约 §2)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum DemoMessage {
|
||
/// 某个演示按钮被按下(`&'static str` = 演示 id,只用于日志)。
|
||
Pressed(&'static str),
|
||
}
|
||
|
||
impl DemoMessage {
|
||
/// 演示 id(日志用;将来加变体时这里跟着改,`app.rs` 不用动)。
|
||
pub fn id(&self) -> &'static str {
|
||
match self {
|
||
DemoMessage::Pressed(id) => id,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// gallery 根视图(整窗:不带标题栏与导航轨,验收图上只有组件)。
|
||
pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||
let page = ui.style(TypeRole::BodySmall);
|
||
|
||
let header = column![
|
||
text("Component gallery")
|
||
.size(ui.emphasized(TypeRole::TitleLarge).size_px)
|
||
.font(ui.emphasized_font(TypeRole::TitleLarge))
|
||
.color(ui.color(Role::OnSurface)),
|
||
text(format!(
|
||
"M3 Expressive component base · board={} · flavor={} · 1180×760 @100% DPI · \
|
||
focus 与 pressed 同为 10%(令牌口径,图上一样是对的)",
|
||
ui.board().name(),
|
||
flavor_slug(ui)
|
||
))
|
||
.size(page.size_px)
|
||
.font(ui.font_of(page))
|
||
.color(ui.color(Role::OnSurfaceVariant)),
|
||
]
|
||
.spacing(ui.space(Space::X1));
|
||
|
||
// 小节之间**不画分隔线**:rubric(15 §22.4)要求同屏同类线 ≤3,
|
||
// 而验收图里要给分隔线自己留证据(见 `divider_proof`)。分组靠 32px 留白 + 标题。
|
||
//
|
||
// 段落过滤(`ULL_GALLERY_SECTION`):**没被选中的段根本不 push**,于是该家族的
|
||
// `view` 一行都不跑——不是「构建后隐藏」。默认 `all` 与加开关之前逐像素一致。
|
||
let picked = Sections::from_env();
|
||
log_sections(picked);
|
||
let mut sections = column![].spacing(ui.space(Space::X8)).width(Length::Fill);
|
||
if picked.buttons {
|
||
sections = sections.push(buttons::section(ui));
|
||
}
|
||
if picked.disabledbase {
|
||
sections = sections.push(disabledbase::section(ui));
|
||
}
|
||
if picked.sizes {
|
||
sections = sections.push(sizes::section(ui));
|
||
}
|
||
if picked.divider {
|
||
sections = sections.push(divider_proof(ui));
|
||
}
|
||
if picked.selection {
|
||
sections = sections.push(selection::section(ui));
|
||
}
|
||
if picked.inputs {
|
||
sections = sections.push(inputs::section(ui));
|
||
}
|
||
if picked.feedback {
|
||
sections = sections.push(feedback::section(ui));
|
||
}
|
||
if picked.navigation {
|
||
sections = sections.push(navigation::section(ui));
|
||
}
|
||
|
||
// 滚动条:默认是主题给的 10px 深灰 thumb,压过组件本体(视觉 reviewer P2-4)。
|
||
// 这里把它改成 **透明轨 + OutlineVariant thumb**;宽度 10px 是 `scrollable` 的内部常量
|
||
// (该控件不暴露 Scrollbar 配置),所以只做了「低对比」这一半,已登记为偏离 D7。
|
||
let body = scrollable(sections)
|
||
.height(Length::Fill)
|
||
.width(Length::Fill)
|
||
.style(move |theme, status| {
|
||
// `scrollable::Style` 没有 `Default`:底色/间隙从主题默认值出发,只覆盖竖轨
|
||
let base = scrollable::default(theme, status);
|
||
scrollable::Style {
|
||
vertical_rail: scrollable::Rail {
|
||
background: None,
|
||
border: iced::Border::default(),
|
||
scroller: scrollable::Scroller {
|
||
background: Background::Color(ui.color(Role::OutlineVariant)),
|
||
border: iced::Border {
|
||
radius: ui.radius(ShapeScale::Full, ui.space(Space::X2)),
|
||
..iced::Border::default()
|
||
},
|
||
},
|
||
},
|
||
..base
|
||
}
|
||
});
|
||
|
||
// 页面留白走 `layout::inset`(左右/上下各 24):这是 Q8/Q9 那两个 helper 的第三个兄弟,
|
||
// 存在意义是「只改内边距、不改尺寸」,也顺手避免把 Padding 的上右下左写反。
|
||
let page_padding = ui.space(Space::X6);
|
||
container(layout::inset(
|
||
page_padding,
|
||
page_padding,
|
||
column![header, body].spacing(ui.space(Space::X4)),
|
||
))
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.style(|_theme| container::Style {
|
||
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
|
||
..container::Style::default()
|
||
})
|
||
.into()
|
||
}
|
||
|
||
/// 分隔线的视觉证据(§22):一条通栏 + 一条列表内缩进,各自带说明。
|
||
///
|
||
/// 单独一节而不是散落各处:`15 §22.4` 说「同屏同类线 ≤3」,这两条是**唯一**计入的两条。
|
||
fn divider_proof<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||
let caption = ui.style(TypeRole::LabelSmall);
|
||
let label = |text_value: &'a str| {
|
||
text(text_value)
|
||
.size(caption.size_px)
|
||
.font(ui.font_of(caption))
|
||
.color(ui.color(Role::OnSurfaceVariant))
|
||
};
|
||
|
||
column![
|
||
heading(
|
||
ui,
|
||
"divider · full / list-inset",
|
||
"15 §22:厚度 1px(stroke::HAIRLINE)· 颜色 OutlineVariant · 列表内左缩进 16 · 同屏 ≤3 条"
|
||
),
|
||
label("full (通栏,区块分隔)"),
|
||
divider::full::<DemoMessage>(ui),
|
||
label("list (左缩进 16)"),
|
||
divider::list::<DemoMessage>(ui),
|
||
]
|
||
.spacing(ui.space(Space::X2))
|
||
.into()
|
||
}
|
||
|
||
/// 一个家族小节的标题(`index.title`)+ 一行判据说明。
|
||
///
|
||
/// 各段共用(`pub(super)`),保证七段的节奏一致——这也是 rubric D4
|
||
/// 「一屏字号档 ≤4」的落点:小节只有 `TitleMedium` + `BodySmall` 两档。
|
||
pub(crate) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<'a, DemoMessage> {
|
||
let note_style = ui.style(TypeRole::BodySmall);
|
||
column![
|
||
text(title)
|
||
.size(ui.emphasized(TypeRole::TitleMedium).size_px)
|
||
.font(ui.emphasized_font(TypeRole::TitleMedium))
|
||
.color(ui.color(Role::OnSurface)),
|
||
text(note)
|
||
.size(note_style.size_px)
|
||
.font(ui.font_of(note_style))
|
||
.color(ui.color(Role::OnSurfaceVariant)),
|
||
]
|
||
.spacing(ui.space(Space::X1))
|
||
.into()
|
||
}
|
||
|
||
/// 状态矩阵的列头:五态的固定次序(`Interaction::ALL` 的口径)。
|
||
///
|
||
/// 列宽 = [`cell_width`](**160** = `Space::X8 × 5`,是矩阵栅格,不是组件尺寸)。
|
||
pub(crate) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
|
||
Interaction::ALL
|
||
.iter()
|
||
.map(|state| (state.name(), cell_width(ui)))
|
||
.collect()
|
||
}
|
||
|
||
/// 矩阵单元格宽度(只影响 harness 的取景,不是组件尺寸)。
|
||
///
|
||
/// 160 = `Space::X8 × 5`:5 列 + 1 个标签列 + 4 个列间距 + 页左右各 24
|
||
/// = 6×160 + 4×8 + 48 = **1040 < 1180**,所以第 5 列(disabled)不再被窗口右缘裁掉。
|
||
/// 早先是 192(×6),总宽 1232 撑破窗口,disabled 列被裁掉一半(视觉 reviewer P2-3)。
|
||
pub(crate) fn cell_width(ui: &Ui) -> f32 {
|
||
ui.space(Space::X8) * 5.0
|
||
}
|
||
|
||
/// 一个矩阵行的左侧标签(变体名)。
|
||
pub(crate) fn row_label<'a>(ui: &'a Ui, name: &'a str) -> Element<'a, DemoMessage> {
|
||
let style = ui.style(TypeRole::BodySmall);
|
||
container(
|
||
text(name)
|
||
.size(style.size_px)
|
||
.font(ui.font_of(style))
|
||
.color(ui.color(Role::OnSurfaceVariant)),
|
||
)
|
||
.width(Length::Fixed(cell_width(ui)))
|
||
.align_y(Alignment::Center)
|
||
.into()
|
||
}
|