Files
Unlucky-Launcher/crates/app-iced/src/components/gallery/mod.rs
T
AskaEth 35dbefb08d feat(app-iced): 禁用态合成底改成容器给的 Base(M234)
`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`
有一行被迫更新(入口多了一个实参,语义不变 = 画布底)。
2026-11-11 06:34:23 +08:00

396 lines
15 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.
//! `--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()
}