feat(app-iced): M76 选择类家族 —— Checkbox / Radio / Switch / Chip(15 §18/§19 + 12b §10/§13)

- selection/:geometry(令牌→像素,缺档登记 G16)/ paint(Interaction×选中×错误→角色)/
  shell(48 透明外壳 + 帧内状态通道 + 勾形 SVG)/ checkbox·radio·switch·chip / matrix(12×5 状态矩阵)/ tests
- gallery/selection.rs:小节 + **渲染层像素断言**(20×20 选中与未选中同尺寸 · Radio 内点 10 ·
  Ø40 状态层是圆 · 勾形 14.8 · 短横 · 轨道 52×32 · Chip 32 · 禁用 38%),按颜色+墨迹块定位、不假设行号
- gallery/mod.rs:新增 ULL_GALLERY_SECTION 段落过滤(tower 批准,M77 零成本复用)+ 四个 helper 放宽到 pub(crate)

A1 证据(本机 AAR javap):未选中 hover/focus/pressed 描边是 OnSurface(不是 OnSurfaceVariant)、
Radio 选中态 hover 仍 Primary、Checkbox SelectedDisabledIconColor=Surface、Switch hover 滑块 PrimaryContainer。
This commit is contained in:
AskaEth committed 2026-11-02 06:11:21 +08:00
1 parent 35345f3399
commit b69a2cdd7f
12 files changed
+2585 -56

No files matched your search

+155 -14
View File
@@ -35,6 +35,32 @@
//! ```
//!
//! `--screenshot` 不带主题时**默认 Light**(可复现;见 `cli::Options::capture_flavor`)。
//!
//! ## 段落过滤:环境变量 `ULL_GALLERY_SECTION`(M76 起)
//!
//! 五段拼成一列、外面套 `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` / `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};
@@ -62,6 +88,107 @@ mod probe;
mod scan;
mod selection;
/// 段落过滤的环境变量名(语义见模块文档「段落过滤」)。
pub const SECTION_ENV: &str = "ULL_GALLERY_SECTION";
/// 段落开关:**构建**哪几段(`false` = 这一段连 `view` 都不跑)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Sections {
buttons: bool,
divider: bool,
selection: bool,
inputs: bool,
feedback: bool,
navigation: bool,
}
impl Sections {
/// 五段全渲染(默认)。
const ALL: Self = Self {
buttons: true,
divider: true,
selection: true,
inputs: true,
feedback: true,
navigation: true,
};
/// 只有一段。
fn only(name: &str) -> Option<Self> {
let mut sections = Self {
buttons: false,
divider: false,
selection: false,
inputs: false,
feedback: false,
navigation: false,
};
match name {
"buttons" => sections.buttons = 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|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.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 {
@@ -100,16 +227,30 @@ pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
// 小节之间**不画分隔线**:rubric(15 §22.4)要求同屏同类线 ≤3,
// 而验收图里要给分隔线自己留证据(见 `divider_proof`)。分组靠 32px 留白 + 标题。
let sections = column![
buttons::section(ui),
divider_proof(ui),
selection::section(ui),
inputs::section(ui),
feedback::section(ui),
navigation::section(ui),
]
.spacing(ui.space(Space::X8))
.width(Length::Fill);
//
// 段落过滤(`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.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` 的内部常量
@@ -184,7 +325,7 @@ fn divider_proof<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
///
/// 五个家族文件共用(`pub(super)`),保证五段的节奏一致——这也是 rubric D4
/// 「一屏字号档 ≤4」的落点:小节只有 `TitleMedium` + `BodySmall` 两档。
pub(super) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<'a, DemoMessage> {
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)
@@ -203,7 +344,7 @@ pub(super) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<
/// 状态矩阵的列头:五态的固定次序(`Interaction::ALL` 的口径)。
///
/// 列宽 = [`cell_width`](**160** = `Space::X8 × 5`,是矩阵栅格,不是组件尺寸)。
pub(super) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
pub(crate) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
Interaction::ALL
.iter()
.map(|state| (state.name(), cell_width(ui)))
@@ -215,12 +356,12 @@ pub(super) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
/// 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(super) fn cell_width(ui: &Ui) -> f32 {
pub(crate) fn cell_width(ui: &Ui) -> f32 {
ui.space(Space::X8) * 5.0
}
/// 一个矩阵行的左侧标签(变体名)。
pub(super) fn row_label<'a>(ui: &'a Ui, name: &'a str) -> Element<'a, DemoMessage> {
pub(crate) fn row_label<'a>(ui: &'a Ui, name: &'a str) -> Element<'a, DemoMessage> {
let style = ui.style(TypeRole::BodySmall);
container(
text(name)