WIP(app-iced): M75 组件基座 checkpoint —— 组件骨架/按钮五式/分隔线/gallery harness
已落地:
- components/{mod,interaction,layout,divider,gate}.rs + buttons/{mod,geometry,assemble,tests}.rs
- gallery/{mod,buttons,probe,scan}.rs + 四个家族桩(selection/inputs/feedback/navigation)
- cli.rs: --screen=shell|gallery + capture_flavor(截图默认 Light,删掉等系统主题的闸门)
- app.rs: gallery 路由 + DemoMessage 接线(400 行)
待办(下一个 commit):编译/测试全绿、浅深矩阵出图、1:1 并排与数值表。
This commit is contained in:
1 parent
22ddc74784
commit
4774e7b788
23 files changed
+3136
-133
No files matched your search
+67
-67
@@ -1,16 +1,8 @@
|
||||
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线。
|
||||
//!
|
||||
//! ## 启动序列(顺序有讲究)
|
||||
//!
|
||||
//! 1. **探测渲染后端** → 写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1);
|
||||
//! 2. **求值字体栈**(D28 前半)→ 一行启动日志;
|
||||
//! 3. `iced::application(..).run()`:窗口 1180×760、无边框(自绘标题栏);
|
||||
//! 4. 首个 `view()` 打「到首帧」耗时;截图模式下第 3 帧 `window::screenshot` 落盘后退出。
|
||||
//!
|
||||
//! ## 为什么截图要等 3 帧
|
||||
//!
|
||||
//! 无边框窗口启动时会先收到一次「显示器工作区尺寸」的 `Resized`,再回到设定尺寸(M60 坑 #5),
|
||||
//! 第一帧可能是旧尺寸;再叠上字体系统首次上屏的排版抖动,留 2 帧余量最稳。
|
||||
//! 启动序列:①探测渲染后端并写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1)→ ②求值字体栈
|
||||
//! (D28 前半)→ ③`iced::application(..).run()`(1180×760、无边框自绘标题栏)→ ④首个 `view()`
|
||||
//! 打耗时;截图模式第 3 帧落盘后退出(无边框窗首帧可能是旧尺寸,M60 坑 #5)。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
@@ -20,23 +12,28 @@ use iced::{window, window::Direction, Element, Subscription, Task};
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::cli::{Options, Screen};
|
||||
use crate::fonts;
|
||||
use crate::probe;
|
||||
use crate::shell::{self, NavItem};
|
||||
use crate::shot;
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
/// 验收窗口尺寸(1180×760,100% DPI —— D30 的硬要求)。
|
||||
// 组件库(D39)在本文件声明:`src/lib.rs` 是五条组件线共用的文件,改它必然冲突;
|
||||
// `#[path]` 保证物理路径仍是 `src/components/`(组件目录自己的归属规则不变)。
|
||||
#[path = "components/mod.rs"]
|
||||
mod components;
|
||||
|
||||
/// 验收窗口尺寸(1180×760 @100% DPI —— D30 的硬要求)。
|
||||
pub const WINDOW_SIZE: iced::Size = iced::Size {
|
||||
width: 1180.0,
|
||||
height: 760.0,
|
||||
};
|
||||
|
||||
/// 产品名(自绘标题栏的标题;也是 `'static`,好让 view 借用)。
|
||||
/// 产品名(自绘标题栏的标题;`'static` 好让 view 借用)。
|
||||
pub const APP_TITLE: &str = "Unlucky Launcher";
|
||||
|
||||
/// 窗口最小尺寸(低于这个值骨架会挤坏:88 导航 + 页面留白 + 卡片)。
|
||||
/// 窗口最小尺寸(再小骨架会挤坏:88 导航 + 页面留白 + 卡片)。
|
||||
const MIN_SIZE: iced::Size = iced::Size {
|
||||
width: 880.0,
|
||||
height: 560.0,
|
||||
@@ -58,8 +55,6 @@ pub struct App {
|
||||
nav: NavItem,
|
||||
window: Option<window::Id>,
|
||||
frames: u32,
|
||||
/// 截图模式的「主题已定」闸门:`system::theme()` 是异步的,早拍会拍到错的深浅色。
|
||||
theme_resolved: bool,
|
||||
}
|
||||
|
||||
/// 消息(单一大枚举;页面接进来时按页拆子枚举)。
|
||||
@@ -69,29 +64,27 @@ pub enum Message {
|
||||
Boot,
|
||||
/// `window::latest()` 的结果。
|
||||
WindowReady(Option<window::Id>),
|
||||
/// 系统主题探测结果。
|
||||
/// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。
|
||||
SystemTheme(iced::theme::Mode),
|
||||
/// 一帧画完(只有截图模式才订阅)。
|
||||
Frame,
|
||||
/// 导航项切换。
|
||||
Select(NavItem),
|
||||
/// 按下标题栏 → 拖窗。
|
||||
/// 按下标题栏 → 拖窗 / 边缘热区 → 缩放窗口。
|
||||
DragWindow,
|
||||
/// 按下边缘热区 → 缩放窗口。
|
||||
ResizeStart(Direction),
|
||||
/// 最小化。
|
||||
/// 最小化 / 最大化还原 / 关闭(三个 caption 按钮)。
|
||||
Minimize,
|
||||
/// 最大化/还原。
|
||||
ToggleMaximize,
|
||||
/// 关闭。
|
||||
Close,
|
||||
/// 截图回来了。
|
||||
Captured(window::Screenshot),
|
||||
/// gallery(组件库 harness)里的演示交互。
|
||||
Demo(components::gallery::DemoMessage),
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
|
||||
let options_theme_given = options.shot_flavor().is_some();
|
||||
Self {
|
||||
ui: Ui::new(flavor, families),
|
||||
families,
|
||||
@@ -99,8 +92,6 @@ impl App {
|
||||
nav: NavItem::Instances,
|
||||
window: None,
|
||||
frames: 0,
|
||||
// 显式给了主题就算已定;否则等 `SystemTheme` 到达
|
||||
theme_resolved: options_theme_given,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +104,8 @@ impl App {
|
||||
Message::Boot => {
|
||||
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
||||
|
||||
// 主题:参数里没指定就探系统(深浅两套都要能出图)
|
||||
if self.options.shot_flavor().is_none() {
|
||||
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
|
||||
if self.options.wants_system_theme() {
|
||||
tasks.push(iced::system::theme().map(Message::SystemTheme));
|
||||
}
|
||||
|
||||
@@ -129,9 +120,8 @@ impl App {
|
||||
iced::theme::Mode::Dark => SchemeFlavor::Dark,
|
||||
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
|
||||
};
|
||||
eprintln!("[theme] 系统主题 = {mode:?} -> {}", slug(flavor));
|
||||
eprintln!("[theme] 系统主题 = {mode:?}");
|
||||
self.set_flavor(flavor);
|
||||
self.theme_resolved = true;
|
||||
Task::none()
|
||||
}
|
||||
Message::Frame => self.on_frame(),
|
||||
@@ -157,6 +147,11 @@ impl App {
|
||||
},
|
||||
Message::Close => iced::exit(),
|
||||
Message::Captured(screenshot) => self.on_captured(&screenshot),
|
||||
Message::Demo(message) => {
|
||||
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
|
||||
eprintln!("[gallery] {} pressed", message.id());
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,14 +169,14 @@ impl App {
|
||||
);
|
||||
self.ui = next;
|
||||
}
|
||||
|
||||
fn on_frame(&mut self) -> Task<Message> {
|
||||
self.frames += 1;
|
||||
|
||||
// 主题没定就不出图:等 `SystemTheme`(否则暗色系统上会拍到浅色图)
|
||||
if !self.has_screenshot() || !self.theme_resolved || self.frames < CAPTURE_AT_FRAME {
|
||||
// 主题在启动时就已确定(截图模式必有主题,见 cli 模块文档),这里只剩帧数闸门
|
||||
if !self.has_screenshot() || self.frames < CAPTURE_AT_FRAME {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match self.window {
|
||||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||||
None => Task::none(),
|
||||
@@ -192,14 +187,14 @@ impl App {
|
||||
let Some(path) = self.options.screenshot.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
match shot::save(screenshot, &path) {
|
||||
Ok(summary) => {
|
||||
eprintln!("{}", summary.describe());
|
||||
eprintln!(
|
||||
"[shot] theme={} renderer={}",
|
||||
"[shot] screen={} theme={} renderer={}",
|
||||
self.options.screen.name(),
|
||||
shot::flavor_slug(&self.ui),
|
||||
renderer_note(self.options.backend)
|
||||
self.options.backend.note()
|
||||
);
|
||||
}
|
||||
Err(error) => eprintln!("[shot] 失败:{error}"),
|
||||
@@ -208,8 +203,8 @@ impl App {
|
||||
iced::exit()
|
||||
}
|
||||
|
||||
/// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
|
||||
if self.wants_frames() {
|
||||
window::frames().map(|_| Message::Frame)
|
||||
} else {
|
||||
@@ -240,6 +235,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
|
||||
// 这里只做一次 map,组件库与业务 Message 依然解耦。
|
||||
if self.options.screen == Screen::Gallery {
|
||||
return components::gallery::view(&self.ui).map(Message::Demo);
|
||||
}
|
||||
|
||||
shell::view(
|
||||
&self.ui,
|
||||
self.nav,
|
||||
@@ -252,27 +253,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn renderer_note(backend: crate::Backend) -> &'static str {
|
||||
if cfg!(feature = "gpu") {
|
||||
match backend {
|
||||
crate::Backend::Cpu => "cpu(forced)",
|
||||
crate::Backend::Gpu => "gpu(preferred)",
|
||||
crate::Backend::Auto => "auto",
|
||||
}
|
||||
} else {
|
||||
"cpu(build without wgpu)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 主题名(日志用,和 `Ui::name()` 同一口径)。
|
||||
fn slug(flavor: SchemeFlavor) -> &'static str {
|
||||
if flavor.is_dark() {
|
||||
"ull-m3-dark"
|
||||
} else {
|
||||
"ull-m3-light"
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
|
||||
pub(crate) fn run(options: Options) -> iced::Result {
|
||||
let _ = STARTED.set(Instant::now());
|
||||
@@ -313,8 +293,12 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
||||
}
|
||||
let families = resolved.families();
|
||||
|
||||
// 截图模式:`--screenshot-theme` > `--theme` > 浅色(后续系统探测会覆盖)
|
||||
let flavor = options.shot_flavor().unwrap_or(SchemeFlavor::Light);
|
||||
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口在 `Options::capture_flavor`)。
|
||||
// 这里**不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
|
||||
if options.screenshot.is_some() && options.shot_flavor().is_none() {
|
||||
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
|
||||
}
|
||||
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
|
||||
let boot_options = options.clone();
|
||||
|
||||
let application = iced::application(
|
||||
@@ -374,16 +358,32 @@ mod tests {
|
||||
#[test]
|
||||
fn window_size_is_the_acceptance_size() {
|
||||
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
|
||||
const {
|
||||
assert!(MIN_SIZE.width < WINDOW_SIZE.width);
|
||||
assert!(MIN_SIZE.height < WINDOW_SIZE.height);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_matches_the_theme_name() {
|
||||
assert_eq!(slug(SchemeFlavor::Light), "ull-m3-light");
|
||||
assert_eq!(slug(SchemeFlavor::Dark), "ull-m3-dark");
|
||||
fn screenshot_mode_never_waits_for_the_system_theme() {
|
||||
// 门禁依赖:CI 的 runtime-smoke 不带主题,xvfb 里也没有主题会话。
|
||||
let options = Options {
|
||||
screenshot: Some(std::path::PathBuf::from("x.png")),
|
||||
..Options::default()
|
||||
};
|
||||
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
|
||||
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
|
||||
|
||||
let app = App::new(options, FAMILIES, SchemeFlavor::Light);
|
||||
assert!(app.has_screenshot() && app.ui.name() == "ull-m3-light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gallery_screen_builds_a_tree() {
|
||||
assert_eq!(Options::default().screen, Screen::Shell, "默认仍是产品骨架");
|
||||
let options = Options {
|
||||
screen: Screen::Gallery,
|
||||
..Options::default()
|
||||
};
|
||||
let app = App::new(options, FAMILIES, SchemeFlavor::Light);
|
||||
// 真实像素由 `--screen=gallery --screenshot` 出图 + `gallery::probe` 量
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+162
-66
@@ -1,7 +1,24 @@
|
||||
//! 命令行参数(只做骨架期需要的那些;业务开关等页面 mission 再加)。
|
||||
//! 命令行参数(骨架期 + 组件库 harness 需要的那些;业务开关等页面 mission 再加)。
|
||||
//!
|
||||
//! 取值语法与现役 Slint 版一致:`--key value` 与 `--key=value` 两种都认
|
||||
//! (`crates/app/src/screenshot.rs` 的 `value_of` 是同一口径),这样将来 CI 换 bin 不用改脚本。
|
||||
//!
|
||||
//! ## 参数总表
|
||||
//!
|
||||
//! | 参数 | 取值 | 语义 |
|
||||
//! |---|---|---|
|
||||
//! | `--software-renderer` | — | 等价 `--renderer=cpu`(D27 的 CPU 档) |
|
||||
//! | `--renderer` | `auto`/`gpu`/`cpu` | 渲染后端请求 |
|
||||
//! | `--theme` | `light`/`dark` | 主题;不给则跟随系统(仅交互模式) |
|
||||
//! | `--screenshot` / `--screenshot-theme` | 路径 / `light`·`dark` | 截图通道(出图后退出)与截图专用主题 |
|
||||
//! | `--screen` | `shell`/`gallery` | 启动画面;**`gallery` = 组件库视觉验收 harness**(D39 §5) |
|
||||
//! | `--scale-factor` | `>0` | 强制缩放因子(验收要 100%,即 `1`) |
|
||||
//! | `--diff` / `--diff-scan` | 两个路径 / 非负整数 | 逐像素对比(不开窗)与 ±n px 平移扫描 |
|
||||
//! | `--force-state` | `hover`/`pressed` | 把整屏可交互控件强制成某态(取证) |
|
||||
//! | `--data-dir` | 路径 | 翻牌兼容:**接受并忽略**(CI 冒烟在用) |
|
||||
//!
|
||||
//! **截图主题默认 Light(门禁依赖,不许改回 None)**,理由与不变量见
|
||||
//! [`Options::capture_flavor`] 与 `app.rs` 的截图路径。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -13,7 +30,7 @@ pub enum Backend {
|
||||
/// 探测后决定:有硬件 GPU 就让 iced 自己走 wgpu→tiny-skia 回退;没有就强制 CPU 起步。
|
||||
#[default]
|
||||
Auto,
|
||||
/// 优先 GPU(写 `ICED_BACKEND=wgpu,tiny-skia`:拿不到适配器仍会降级,不硬失败)。
|
||||
/// 优先 GPU(写 `ICED_BACKEND=wgpu,tiny-skia`;拿不到适配器仍会降级,不硬失败)。
|
||||
Gpu,
|
||||
/// 强制 CPU(`--software-renderer` 的等价物,写 `ICED_BACKEND=tiny-skia`)。
|
||||
Cpu,
|
||||
@@ -28,10 +45,42 @@ impl Backend {
|
||||
Backend::Cpu => "cpu",
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图日志用的后端说明(`cfg!(feature = "gpu")` 决定有没有 wgpu 这一档)。
|
||||
pub fn note(self) -> &'static str {
|
||||
if !cfg!(feature = "gpu") {
|
||||
return "cpu(build without wgpu)";
|
||||
}
|
||||
match self {
|
||||
Backend::Cpu => "cpu(forced)",
|
||||
Backend::Gpu => "gpu(preferred)",
|
||||
Backend::Auto => "auto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动画面(D39 §5:`gallery` 是视觉验收 harness,不是产品页面)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Screen {
|
||||
/// 产品骨架:自绘标题栏 + 导航轨 + 内容区。
|
||||
#[default]
|
||||
Shell,
|
||||
/// 组件库 harness:整窗只放状态矩阵。
|
||||
Gallery,
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
/// 稳定名字(日志用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Screen::Shell => "shell",
|
||||
Screen::Gallery => "gallery",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析后的启动参数。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Options {
|
||||
/// 渲染后端请求。
|
||||
pub backend: Backend,
|
||||
@@ -39,24 +88,26 @@ pub struct Options {
|
||||
pub theme: Option<SchemeFlavor>,
|
||||
/// 截图通道:给了路径就出图后退出。
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// 截图时用的主题(`--screenshot-theme`,优先级高于 `--theme`)。
|
||||
/// `--screenshot-theme`(优先级高于 `--theme`)。
|
||||
pub screenshot_theme: Option<SchemeFlavor>,
|
||||
/// 强制窗口缩放因子(截图验收要 100% DPI;`None` = 用系统 DPI)。
|
||||
/// 强制窗口缩放因子(验收要 100% DPI,即 `1`;`None` = 用系统 DPI)。
|
||||
pub scale_factor: Option<f32>,
|
||||
/// `--diff <left> <right>`:只做两图逐像素对比,不开窗。
|
||||
/// `--diff <left> <right>` 与 `--diff-scan <n>`:逐像素对比(不开窗)与 ±n px 平移扫描。
|
||||
pub diff: Option<(PathBuf, PathBuf)>,
|
||||
/// `--diff-scan <n>`:对比时额外做 ±n px 的平移扫描(找最佳对齐,默认 0 = 不扫)。
|
||||
/// 见上(默认 0 = 不扫)。
|
||||
pub diff_scan: u32,
|
||||
/// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。
|
||||
pub force_state: Option<ForcedState>,
|
||||
/// `--data-dir <dir>`:当前**接受并忽略**。
|
||||
///
|
||||
/// 现役 Slint 版的 CI 冒烟用 `--software-renderer --screenshot <png> --data-dir <dir>`,
|
||||
/// 翻牌后同一套脚本要能直接跑 iced 版;真正的数据目录消费由后续页面 mission 接 `host`/`state` 时做。
|
||||
/// `--screen=shell|gallery`:启动画面(`gallery` = 组件库视觉验收 harness)。
|
||||
pub screen: Screen,
|
||||
/// `--data-dir <dir>`:当前**接受并忽略**(现役 Slint 版 CI 冒烟在用;
|
||||
/// 真正的消费由后续页面 mission 接 `host`/`state` 时做)。
|
||||
pub data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。
|
||||
///
|
||||
/// 五态完整矩阵由 `--screen=gallery` 的 `Button::state(..)` 出,本开关只给整屏取证。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ForcedState {
|
||||
/// 所有可交互控件按 hover 画。
|
||||
@@ -65,37 +116,11 @@ pub enum ForcedState {
|
||||
Pressed,
|
||||
}
|
||||
|
||||
impl ForcedState {
|
||||
/// 稳定名字(日志用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
ForcedState::Hover => "hover",
|
||||
ForcedState::Pressed => "pressed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: Backend::Auto,
|
||||
theme: None,
|
||||
screenshot: None,
|
||||
screenshot_theme: None,
|
||||
scale_factor: None,
|
||||
diff: None,
|
||||
diff_scan: 0,
|
||||
force_state: None,
|
||||
data_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
/// 从进程参数解析。
|
||||
/// 从进程参数解析(错误信息是人话,不 panic、不 `exit`)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 参数值不合法(未知的 `--renderer` / `--theme` / 非法 `--scale-factor`)时返回人话。
|
||||
/// 取值不合法(未知 `--renderer`/`--theme`/`--screen`、非法 `--scale-factor`)时返回人话。
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
Self::parse(std::env::args().skip(1))
|
||||
}
|
||||
@@ -173,6 +198,16 @@ impl Options {
|
||||
}
|
||||
});
|
||||
}
|
||||
"--screen" => {
|
||||
let raw = take(&mut args, inline, "--screen")?;
|
||||
options.screen = match raw.as_str() {
|
||||
"shell" => Screen::Shell,
|
||||
"gallery" => Screen::Gallery,
|
||||
other => {
|
||||
return Err(format!("--screen 只认 shell|gallery,收到 {other:?}"));
|
||||
}
|
||||
};
|
||||
}
|
||||
// 翻牌前的兼容参数:接受并忽略(见字段文档)
|
||||
"--data-dir" => {
|
||||
options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?));
|
||||
@@ -184,10 +219,30 @@ impl Options {
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// 截图时实际使用的主题(截图专用参数优先)。
|
||||
/// 截图时实际使用的主题(**只表达用户显式给了什么**;默认值见 [`Options::capture_flavor`])。
|
||||
pub fn shot_flavor(&self) -> Option<SchemeFlavor> {
|
||||
self.screenshot_theme.or(self.theme)
|
||||
}
|
||||
|
||||
/// 本次进程真正要画的主题(**唯一取值口**)。
|
||||
///
|
||||
/// 截图模式(给了 `--screenshot`)**永远返回 `Some`**:`--screenshot-theme` > `--theme` > Light。
|
||||
/// 交互模式只有显式指定才有值(`None` = 等系统主题)。
|
||||
///
|
||||
/// ⚠️ **门禁依赖,不许把截图分支改回 `None`**:CI 的 runtime-smoke 打
|
||||
/// `--software-renderer --screenshot <png> --data-dir <dir>`(不带主题),xvfb 里没有桌面主题
|
||||
/// 会话,M68 的「等系统主题到达再出图」闸门在这条路径上会把 runner 挂到超时(不是红,更难查)。
|
||||
pub fn capture_flavor(&self) -> Option<SchemeFlavor> {
|
||||
match self.screenshot {
|
||||
Some(_) => Some(self.shot_flavor().unwrap_or(SchemeFlavor::Light)),
|
||||
None => self.shot_flavor(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否要问系统主题。截图模式**永远不问**——这就是「无主题环境不会卡住」的不变量。
|
||||
pub fn wants_system_theme(&self) -> bool {
|
||||
self.capture_flavor().is_none()
|
||||
}
|
||||
}
|
||||
|
||||
fn take(
|
||||
@@ -217,11 +272,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_auto_and_system_theme() {
|
||||
fn defaults_are_auto_system_theme_and_shell() {
|
||||
let options = parse(&[]).expect("空参数要能解析");
|
||||
assert_eq!(options.backend, Backend::Auto);
|
||||
assert_eq!(options.theme, None);
|
||||
assert_eq!(options.screenshot, None);
|
||||
assert_eq!((options.theme, options.screenshot), (None, None));
|
||||
assert_eq!(options.screen, Screen::Shell);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -235,29 +290,51 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_accepts_both_argv_forms() {
|
||||
fn both_argv_forms_are_accepted() {
|
||||
let spaced = parse(&["--screenshot", "out.png"]).unwrap();
|
||||
let inline = parse(&["--screenshot=out.png"]).unwrap();
|
||||
assert_eq!(spaced.screenshot, Some(PathBuf::from("out.png")));
|
||||
assert_eq!(spaced.screenshot, inline.screenshot);
|
||||
assert_eq!(
|
||||
parse(&["--diff", "a.png", "b.png"]).unwrap().diff,
|
||||
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--screen", "gallery"]).unwrap().screen,
|
||||
Screen::Gallery
|
||||
);
|
||||
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
|
||||
assert_eq!(
|
||||
parse(&["--force-state", "hover"]).unwrap().force_state,
|
||||
Some(ForcedState::Hover)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_theme_wins_over_theme() {
|
||||
let options = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
|
||||
assert_eq!(options.theme, Some(SchemeFlavor::Dark));
|
||||
assert_eq!(options.shot_flavor(), Some(SchemeFlavor::Light));
|
||||
let o = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
|
||||
assert_eq!(
|
||||
(o.theme, o.shot_flavor()),
|
||||
(Some(SchemeFlavor::Dark), Some(SchemeFlavor::Light))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_values_are_reported_not_panicked() {
|
||||
assert!(parse(&["--renderer=metal"]).is_err());
|
||||
assert!(parse(&["--theme=blue"]).is_err());
|
||||
assert!(parse(&["--scale-factor=0"]).is_err());
|
||||
assert!(parse(&["--scale-factor=abc"]).is_err());
|
||||
assert!(parse(&["--screenshot"]).is_err());
|
||||
assert!(parse(&["--diff", "only-one.png"]).is_err());
|
||||
assert!(parse(&["--nope"]).is_err());
|
||||
for args in [
|
||||
&["--renderer=metal"][..],
|
||||
&["--theme=blue"],
|
||||
&["--scale-factor=0"],
|
||||
&["--scale-factor=abc"],
|
||||
&["--screenshot"],
|
||||
&["--diff", "only-one.png"],
|
||||
&["--screen=settings"],
|
||||
&["--force-state=focus"],
|
||||
&["--diff-scan=-1"],
|
||||
&["--nope"],
|
||||
] {
|
||||
assert!(parse(args).is_err(), "{args:?} 应该报错");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -276,23 +353,42 @@ mod tests {
|
||||
assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x")));
|
||||
}
|
||||
|
||||
/// **门禁依赖**:CI 的 runtime-smoke 不带主题,截图模式必须有确定主题。
|
||||
/// 退化成 `None` 的话,xvfb 里的 runner 会挂到超时(不是红,更难查)。
|
||||
#[test]
|
||||
fn diff_scan_and_force_state_parse() {
|
||||
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
|
||||
fn screenshot_without_theme_defaults_to_light() {
|
||||
let options = parse(&["--screenshot", "out.png"]).unwrap();
|
||||
assert_eq!(options.shot_flavor(), None, "用户没给主题");
|
||||
assert_eq!(
|
||||
parse(&["--force-state", "hover"]).unwrap().force_state,
|
||||
Some(ForcedState::Hover)
|
||||
options.capture_flavor(),
|
||||
Some(SchemeFlavor::Light),
|
||||
"截图模式必须给确定主题(CI 冒烟依赖这条)"
|
||||
);
|
||||
assert!(parse(&["--force-state=focus"]).is_err());
|
||||
assert!(parse(&["--diff-scan=-1"]).is_err());
|
||||
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_takes_two_paths() {
|
||||
let options = parse(&["--diff", "a.png", "b.png"]).unwrap();
|
||||
assert_eq!(
|
||||
options.diff,
|
||||
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
|
||||
);
|
||||
fn explicit_themes_still_win_in_screenshot_mode() {
|
||||
let dark = parse(&["--screenshot", "a.png", "--theme=dark"]).unwrap();
|
||||
assert_eq!(dark.capture_flavor(), Some(SchemeFlavor::Dark));
|
||||
|
||||
let both = parse(&[
|
||||
"--screenshot",
|
||||
"a.png",
|
||||
"--theme=dark",
|
||||
"--screenshot-theme=light",
|
||||
]);
|
||||
assert_eq!(both.unwrap().capture_flavor(), Some(SchemeFlavor::Light));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_mode_still_follows_the_system_theme() {
|
||||
let interactive = parse(&[]).unwrap();
|
||||
assert_eq!(interactive.capture_flavor(), None);
|
||||
assert!(interactive.wants_system_theme(), "交互模式等系统主题");
|
||||
|
||||
let pinned = parse(&["--theme=dark"]).unwrap();
|
||||
assert_eq!(pinned.capture_flavor(), Some(SchemeFlavor::Dark));
|
||||
assert!(!pinned.wants_system_theme());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 装配与样式:五式共用的一条产线(尺寸 / 内边距 / 图标盒 / 状态层 / 描边)。
|
||||
//!
|
||||
//! 单独成文件的原因:`mod.rs` 已经是「对外契约 + 变体角色表」,把绘制细节放一起会顶到 400 行红线;
|
||||
//! 而且这条产线是**唯一**会把「令牌 → 像素」翻一次的地方,独立出来才好审。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{button, text, Row};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Pixels, Shadow};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::elevation::Elevation;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::{Ui, STROKE_PX};
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::super::layout;
|
||||
use super::{Geometry, Variant};
|
||||
|
||||
/// 一个按钮样式要的全部输入(避免 `style_closure` 长出七个位置参数)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct StyleSpec {
|
||||
/// 容器填充角色;`None` = 透明(Outlined / Text / StandardIcon)。
|
||||
pub fill: Option<Role>,
|
||||
/// 内容(文字 + 图标)角色。
|
||||
pub content: Role,
|
||||
/// 描边角色;`None` = 无描边。
|
||||
pub border: Option<Role>,
|
||||
/// 圆角档位(来自组件表,五式统一 `Full`)。
|
||||
pub shape: ShapeScale,
|
||||
/// 圆角折算用的边长(药丸 = 可见高)。
|
||||
pub radius_side: f32,
|
||||
/// 强制态(gallery 取证);`Enabled` 时跟随运行期的 `button::Status`。
|
||||
pub forced: Interaction,
|
||||
}
|
||||
|
||||
/// 五式共用的装配:**只画一处**,五个变体函数都走这里。
|
||||
pub(super) fn assemble<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
variant: Variant,
|
||||
spec: super::Button<'a, M>,
|
||||
) -> Element<'a, M> {
|
||||
let geometry = Geometry::of(ui, variant, spec.leading.is_some());
|
||||
let (fill, content, border) = variant.roles();
|
||||
|
||||
// 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影)
|
||||
debug_assert!(
|
||||
!matches!(
|
||||
variant.elevation(),
|
||||
Elevation::Level3 | Elevation::Level4 | Elevation::Level5
|
||||
),
|
||||
"按钮家族不许有浮层高程"
|
||||
);
|
||||
let content_color = ui.color(content);
|
||||
|
||||
let label = text(spec.label)
|
||||
.size(ui.style(TypeRole::LabelLarge).size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(
|
||||
ui.style(TypeRole::LabelLarge).line_height_px,
|
||||
)))
|
||||
.font(ui.font_of(ui.emphasized(TypeRole::LabelLarge)))
|
||||
.color(content_color);
|
||||
|
||||
let mut row = Row::new().align_y(Alignment::Center);
|
||||
if let Some(kind) = spec.leading {
|
||||
// Q9:图标一律经固定尺寸容器(按钮图标 16、IconButton 24 都不例外)
|
||||
row = row.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, content_color),
|
||||
));
|
||||
}
|
||||
row = row.push(label);
|
||||
if let Some(kind) = spec.trailing {
|
||||
row = row.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, content_color),
|
||||
));
|
||||
}
|
||||
let row = row.spacing(geometry.gap);
|
||||
|
||||
// **垂直居中必须在内容侧做**:iced 的 `button::layout` 把内容放在 padding-box 的左上角
|
||||
// (`layout::padded` 只 `move_to(padding.top)`,不做居中),所以内容自己撑满高度再居中。
|
||||
// 少了这一层,40 高的按钮里文字会贴上沿 10px(实测首版就是这样)。
|
||||
let content_box = iced::widget::container(row)
|
||||
.height(Length::Fill)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let forced = spec.state.unwrap_or(if spec.on_press.is_none() {
|
||||
Interaction::Disabled
|
||||
} else {
|
||||
Interaction::Enabled
|
||||
});
|
||||
|
||||
let style = style_closure(
|
||||
ui,
|
||||
StyleSpec {
|
||||
fill,
|
||||
content,
|
||||
border,
|
||||
shape: variant.shape(),
|
||||
radius_side: geometry.height,
|
||||
forced,
|
||||
},
|
||||
);
|
||||
|
||||
let element = button(content_box)
|
||||
.height(Length::Fixed(geometry.height))
|
||||
.padding(geometry.padding)
|
||||
.style(style);
|
||||
|
||||
match spec.on_press {
|
||||
Some(message) => element.on_press(message).into(),
|
||||
None => element.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 `button::style` 闭包:状态层 / 禁用 / 描边全在这里落地。
|
||||
pub(super) fn style_closure(
|
||||
ui: &Ui,
|
||||
spec: StyleSpec,
|
||||
) -> impl Fn(&iced::Theme, button::Status) -> button::Style + '_ {
|
||||
move |_theme: &iced::Theme, status| {
|
||||
// 强制态优先(gallery 状态矩阵),否则把 iced 的四态翻成 M3 五态
|
||||
let state = match spec.forced {
|
||||
Interaction::Enabled => Interaction::from_status(status),
|
||||
forced => forced,
|
||||
};
|
||||
|
||||
let (background, text_color) = if state.is_disabled() {
|
||||
let (container, content) = interaction::disabled_colors(ui, Role::Surface);
|
||||
// 透明容器在禁用态依然透明(官方只改内容透明度)
|
||||
(spec.fill.map(|_| container), content)
|
||||
} else {
|
||||
// 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见)
|
||||
let background = match spec.fill {
|
||||
Some(role) => match state.layer() {
|
||||
Some(_) => interaction::overlay_color(ui, state, spec.content, role)
|
||||
.or(Some(ui.color(role))),
|
||||
None => Some(ui.color(role)),
|
||||
},
|
||||
None => transparent_state_color(ui, state, spec.content),
|
||||
};
|
||||
(background, ui.color(spec.content))
|
||||
};
|
||||
|
||||
let border_color = if state.is_disabled() {
|
||||
interaction::disabled_colors(ui, Role::Surface).0
|
||||
} else {
|
||||
spec.border
|
||||
.map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role))
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background: background.map(Background::Color),
|
||||
text_color,
|
||||
border: Border {
|
||||
color: if spec.border.is_some() {
|
||||
border_color
|
||||
} else {
|
||||
iced::Color::TRANSPARENT
|
||||
},
|
||||
width: if spec.border.is_some() {
|
||||
STROKE_PX
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
radius: ui.radius(spec.shape, spec.radius_side),
|
||||
},
|
||||
// rubric §7:L0/L1 不用阴影(Elevated 的 L1 由容器色调差表达)
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 透明容器的状态层:`Outlined` / `Text` / `StandardIcon` 没有填充,可见反馈只能来自
|
||||
/// 叠在**内容角色**上的状态层——把它们当作「叠在表面色上」求值,得到不透明实色。
|
||||
///
|
||||
/// 之所以单独一个函数:这是 M3 里唯一「容器不存在但状态层存在」的情形,
|
||||
/// 三个变体共用一条口径,免得各自发挥。
|
||||
pub(super) fn transparent_state_color(
|
||||
ui: &Ui,
|
||||
state: Interaction,
|
||||
content: Role,
|
||||
) -> Option<iced::Color> {
|
||||
interaction::overlay_color(ui, state, content, Role::Surface)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! 按钮家族的**内部几何**:一个数值都不写,全部由 `unlucky_design` 的令牌推导。
|
||||
//!
|
||||
//! | 量 | 令牌 | 值(DESKTOP / 100% DPI) |
|
||||
//! |---|---|---|
|
||||
//! | 可见高 | `LayoutMetrics::control_height` | 40 `[源]` 12b 1.1 |
|
||||
//! | 圆角 | `ShapeScale::Full.radius_px(高)` | 20 `[源]` 12b 1.2 |
|
||||
//! | 左右内边距(无图标) | `Space::X6` | 24 / 24 `[源]` 12b 1.3 |
|
||||
//! | 左内边距(带前置图标) | `Space::X4` | 16 `[源]` 12b 1.4 |
|
||||
//! | 图标边长 | `IconSize::Button` | 16(**官方 18 → G14 偏离**) |
|
||||
//! | 图标↔文字 | `Space::X2` | 8 `[源]` 12b 1.6 |
|
||||
//! | 描边 | `unlucky_design::stroke::HAIRLINE`(`crate::theme::STROKE_PX` 同一真源) | 1 `[源]` rubric G7 |
|
||||
//! | IconButton 边长 | `LayoutMetrics::control_height` | 40 `[源]` 12b 2.1 |
|
||||
//! | IconButton 图标 / 边距 | `IconSize::Toolbar` / `Space::X2` | 24 / 8 `[源]` 12b 2.2 |
|
||||
//!
|
||||
//! **没有落点的两个参考值**(登记在报告里,不在此处写数):
|
||||
//!
|
||||
//! - 12b 1.7 最小宽 **58** → `G15`:iced 的 `Length` 没有 min-width 语义;按「24 + 24 内边距 +
|
||||
//! LabelLarge 二字」自然宽度 ≈ 76,实际不触发,故本 mission 不施加该约束;
|
||||
//! - 12b 1.6 图标 **18** → `G14`:令牌层 `IconSize::Button` 是 16、没有 18 档,
|
||||
//! 组件层又不许写字面量 → 先用 16 并登记 2px 偏离。
|
||||
//!
|
||||
//! 这两条是**能力缺口不是审美选择**,登记后由令牌层(`crates/design`)补档位。
|
||||
|
||||
use iced::Padding;
|
||||
|
||||
use unlucky_design::icons::IconSize;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::theme::{Ui, STROKE_PX};
|
||||
|
||||
use super::Variant;
|
||||
|
||||
/// 文字按钮的几何。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Geometry {
|
||||
/// 可见高(药丸圆角按它折算)。
|
||||
pub height: f32,
|
||||
/// 内边距(上下 0;左右按有无前置图标取 16 或 24)。
|
||||
pub padding: Padding,
|
||||
/// 图标盒边长。
|
||||
pub icon: f32,
|
||||
/// 图标 ↔ 文字间距。
|
||||
pub gap: f32,
|
||||
/// 描边宽度;`0` = 该式无描边。
|
||||
pub stroke: f32,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
/// 按变体 + 有没有前置图标求值(带图标时左内边距收到 16,右仍 24)。
|
||||
pub fn of(ui: &Ui, variant: Variant, leading_icon: bool) -> Self {
|
||||
let (_, _, border) = variant.roles();
|
||||
let start = if leading_icon {
|
||||
ui.space(Space::X4)
|
||||
} else {
|
||||
ui.space(Space::X6)
|
||||
};
|
||||
|
||||
Self {
|
||||
height: ui.metrics().control_height,
|
||||
padding: Padding {
|
||||
top: 0.0,
|
||||
right: ui.space(Space::X6),
|
||||
bottom: 0.0,
|
||||
left: start,
|
||||
},
|
||||
icon: IconSize::Button.px(),
|
||||
gap: ui.space(Space::X2),
|
||||
stroke: if border.is_some() { STROKE_PX } else { 0.0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IconButton 的几何(正方形,图标 24、边距 8)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct IconGeometry {
|
||||
/// 容器边长(40,命中区 48 由父级留白补足)。
|
||||
pub side: f32,
|
||||
/// 图标盒边长(24)——**必须**经 `layout::centered_fixed` 锁住,否则 Q9 会把字形撑满。
|
||||
pub icon: f32,
|
||||
/// 图标到容器边的默认边距(8;narrow 4 / wide 14 属变体参数,本 mission 不用)。
|
||||
pub padding: f32,
|
||||
}
|
||||
|
||||
impl IconGeometry {
|
||||
/// 从令牌求值。
|
||||
pub fn of(ui: &Ui) -> Self {
|
||||
Self {
|
||||
side: ui.metrics().control_height,
|
||||
icon: IconSize::Toolbar.px(),
|
||||
padding: ui.space(Space::X2),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! 按钮家族(`research/12b` §1 五式 + §2 IconButton)。
|
||||
//!
|
||||
//! ## 参考值(全部 `[源]` = A1,`material3-android:1.4.0` 的 `*Tokens.kt` / `Button.kt`)
|
||||
//!
|
||||
//! | # | 检查项 | 参考值 | 本模块落点 |
|
||||
//! |---|---|---|---|
|
||||
//! | 1.1 | 容器可见高 | **40** | `LM.control_height`([`Geometry::height`]) |
|
||||
//! | 1.2 | 圆角 | **full = 高/2 = 20** | `ShapeScale::Full` + `Ui::radius` |
|
||||
//! | 1.3 | 水平内边距(无图标) | **24 / 24** | `Space::X6` |
|
||||
//! | 1.4 | 水平内边距(带前置图标) | **16(左)/ 24(右)** | `Space::X4` / `Space::X6` |
|
||||
//! | 1.5 | 字级 | **LabelLarge 14/20 w500** | `TypeRole::LabelLarge` 强调档 |
|
||||
//! | 1.6 | 图标 18 + 图标↔文字 8 | 图标见 **G14 偏离**(令牌只有 16);间距 `Space::X2` | [`Geometry`] |
|
||||
//! | 1.7 | 最小宽 58 | **G15**:iced 的 `Length` 无 min-width,不施加(实测见报告) | — |
|
||||
//! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 12% & 内容 38% | [`super::interaction`] |
|
||||
//! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated `SurfaceContainerLow`/`Primary`;Outlined 透明 + 1dp `OutlineVariant` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] |
|
||||
//! | 2.1 | IconButton 容器 | **40×40**、圆角 full | [`IconGeometry`] |
|
||||
//! | 2.2 | IconButton 图标 / 边距 | 图标 **24**、边距 **8** | [`IconGeometry`] |
|
||||
//! | 2.4 | Standard 各态**图标色不变** | 只有状态层变化 | [`Variant::roles`] 与态无关 |
|
||||
//! | 2.7 | IconButton 禁用 | 内容 `OnSurface`@38% | [`super::interaction::disabled_colors`] |
|
||||
//! | 2.8 | 命中区 | 容器 40 + 四周各 4 = **48** | `LM.touch_target`(按钮自身不撑高) |
|
||||
//!
|
||||
//! ## 与契约 §2 的签名差异(**已登记,请 tower 确认**)
|
||||
//!
|
||||
//! 契约 §2 的示例签名是 `filled(ui, label, on_press)`——三个参数装不下
|
||||
//! 「五式 × 五态 × 前后双图标 × 禁用」。所以本模块的落地是**契约的加法版**:
|
||||
//! 泛型纪律一字不改(`M: Clone`,不碰 `app::Message`),但把前两个参数收进 [`Button`]:
|
||||
//!
|
||||
//! ```text
|
||||
//! 契约示例: filled(ui, "保存", Message::Save)
|
||||
//! 本模块: filled(ui, Button::new("保存").press(Message::Save))
|
||||
//! ```
|
||||
//!
|
||||
//! 差别只是「label + 消息」两件事从位置参数变成构造器参数,语义一一对应;
|
||||
//! 要图标/禁用/焦点取证时再加链式调用(`.leading(..)` / `Button::disabled(..)` / `.state(..)`),
|
||||
//! 不需要第二个签名。**这是本周唯一一处偏离契约示例的地方**,报告「决策」一节有登记。
|
||||
//!
|
||||
//! ## 为什么不用 iced 内置的 `theme::Button` 样式
|
||||
//!
|
||||
//! iced 的内置按钮样式只有 `primary/success/danger/text` 几档,且**不带 M3 的状态层 α**
|
||||
//! (读的是 `Palette` 现成色)。D30 要求 hover 8% / pressed 10% 可判定,所以样式自绘;
|
||||
//! 但**只画颜色与描边**——尺寸仍由 `button` 的 width/height/padding 决定,不手搓几何。
|
||||
|
||||
use iced::widget::button;
|
||||
use iced::{Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::Elevation;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::interaction::Interaction;
|
||||
use super::layout;
|
||||
|
||||
mod assemble;
|
||||
mod geometry;
|
||||
|
||||
pub use geometry::{Geometry, IconGeometry};
|
||||
|
||||
use assemble::{assemble, style_closure, StyleSpec};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// 按钮的五种视觉变体(12b §1.9 的色彩角色,一名一式)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Variant {
|
||||
/// 实心强调色:**每屏最多一个**(rubric K2)。
|
||||
Filled,
|
||||
/// 次强调(tonal):`SecondaryContainer`。
|
||||
Tonal,
|
||||
/// 带高程:`SurfaceContainerLow` + `Primary` 文本(L1 用色调差,不画阴影)。
|
||||
Elevated,
|
||||
/// 描边:透明容器 + 1dp `OutlineVariant`。
|
||||
Outlined,
|
||||
/// 纯文本:最低权重。
|
||||
Text,
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
/// 五式全序(gallery 的矩阵按这个次序出图)。
|
||||
pub const ALL: [Variant; 5] = [
|
||||
Variant::Filled,
|
||||
Variant::Tonal,
|
||||
Variant::Elevated,
|
||||
Variant::Outlined,
|
||||
Variant::Text,
|
||||
];
|
||||
|
||||
/// 稳定名字(图例 / 文件名 / 失败信息用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Filled => "filled",
|
||||
Variant::Tonal => "tonal",
|
||||
Variant::Elevated => "elevated",
|
||||
Variant::Outlined => "outlined",
|
||||
Variant::Text => "text",
|
||||
}
|
||||
}
|
||||
|
||||
/// 该式在组件表里的档位(圆角/字级)。`Component` 没有 `Outlined/Elevated` 变体,
|
||||
/// 借用 `PrimaryButton`:三者判据值相同(`Full` 圆角 + `LabelLarge` + L0)。
|
||||
pub(crate) const fn component(self) -> Component {
|
||||
match self {
|
||||
Variant::Filled => Component::PrimaryButton,
|
||||
Variant::Tonal => Component::SecondaryButton,
|
||||
Variant::Text => Component::TextButton,
|
||||
Variant::Elevated | Variant::Outlined => Component::PrimaryButton,
|
||||
}
|
||||
}
|
||||
|
||||
/// 圆角档位(五式统一 `Full`,12b 1.2)。
|
||||
pub(crate) fn shape(self) -> ShapeScale {
|
||||
self.component().spec().shape
|
||||
}
|
||||
|
||||
/// 高程:只有 Elevated 是 L1(`ElevatedButtonTokens.ContainerElevation`),其余 L0。
|
||||
///
|
||||
/// **不画阴影**:rubric §7「L0/L1 不应有可见阴影(用 tone 差)」,所以 L1 只体现为
|
||||
/// `SurfaceContainerLow` 容器色,`button::Style.shadow` 永远 default。
|
||||
pub(crate) const fn elevation(self) -> Elevation {
|
||||
match self {
|
||||
Variant::Elevated => Elevation::Level1,
|
||||
_ => Elevation::Level0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 色彩角色三元组 `(容器, 内容, 描边)`;`None` = 透明容器 / 无描边。
|
||||
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
|
||||
match self {
|
||||
Variant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
|
||||
Variant::Tonal => (
|
||||
Some(Role::SecondaryContainer),
|
||||
Role::OnSecondaryContainer,
|
||||
None,
|
||||
),
|
||||
Variant::Elevated => (Some(Role::SurfaceContainerLow), Role::Primary, None),
|
||||
Variant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::OutlineVariant)),
|
||||
Variant::Text => (None, Role::OnSurfaceVariant, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IconButton 的三种容器变体(12b §2.5)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum IconVariant {
|
||||
/// 标准:容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
|
||||
Standard,
|
||||
/// 实心:`Primary` + `OnPrimary`。
|
||||
Filled,
|
||||
/// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。
|
||||
Tonal,
|
||||
}
|
||||
|
||||
impl IconVariant {
|
||||
/// 三式全序(gallery 顺序)。
|
||||
pub const ALL: [IconVariant; 3] = [
|
||||
IconVariant::Standard,
|
||||
IconVariant::Filled,
|
||||
IconVariant::Tonal,
|
||||
];
|
||||
|
||||
/// 稳定名字。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
IconVariant::Standard => "standard",
|
||||
IconVariant::Filled => "filled",
|
||||
IconVariant::Tonal => "tonal",
|
||||
}
|
||||
}
|
||||
|
||||
/// 色彩角色三元组(同 [`Variant::roles`] 的口径)。
|
||||
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
|
||||
match self {
|
||||
IconVariant::Standard => (None, Role::OnSurfaceVariant, None),
|
||||
IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
|
||||
IconVariant::Tonal => (
|
||||
Some(Role::SecondaryContainer),
|
||||
Role::OnSecondaryContainer,
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个按钮的装配描述(五式共用;**不持有任何状态所有权**)。
|
||||
///
|
||||
/// `on_press: Option<M>` 就是 M3 的 enabled/disabled 开关——`None` = 禁用,
|
||||
/// 与 `button::on_press_maybe` 的语义天然对齐(省掉一个 `disabled: bool` 字段,
|
||||
/// 也就不会出现「有消息但画成禁用」这种自相矛盾的状态)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Button<'a, M> {
|
||||
label: &'a str,
|
||||
on_press: Option<M>,
|
||||
leading: Option<IconKind>,
|
||||
trailing: Option<IconKind>,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> Button<'a, M> {
|
||||
/// 一个可点的按钮。
|
||||
pub fn new(label: &'a str, on_press: M) -> Self {
|
||||
Self {
|
||||
label,
|
||||
on_press: Some(on_press),
|
||||
leading: None,
|
||||
trailing: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个禁用的按钮(无消息)。禁用不是「另一种颜色」,见 `interaction::disabled_colors`。
|
||||
pub fn disabled(label: &'a str) -> Self {
|
||||
Self {
|
||||
label,
|
||||
on_press: None,
|
||||
leading: None,
|
||||
trailing: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 前置图标(12b 1.4:带图标时左内边距收到 16)。
|
||||
pub fn leading(mut self, kind: IconKind) -> Self {
|
||||
self.leading = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 后置图标(右内边距保持 24)。
|
||||
pub fn trailing(mut self, kind: IconKind) -> Self {
|
||||
self.trailing = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 强制视觉态(`--screen=gallery` 的状态矩阵取证用;运行期不要调)。
|
||||
pub fn state(mut self, state: Interaction) -> Self {
|
||||
self.state = Some(state);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Filled 按钮(实心 `Primary`)。
|
||||
pub fn filled<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Filled, button)
|
||||
}
|
||||
|
||||
/// Tonal 按钮(`SecondaryContainer`)。
|
||||
pub fn tonal<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Tonal, button)
|
||||
}
|
||||
|
||||
/// Elevated 按钮(`SurfaceContainerLow` + `Primary` 文本,L1 用色调差)。
|
||||
pub fn elevated<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Elevated, button)
|
||||
}
|
||||
|
||||
/// Outlined 按钮(透明 + 1dp `OutlineVariant`)。
|
||||
pub fn outlined<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Outlined, button)
|
||||
}
|
||||
|
||||
/// Text 按钮(无容器、无描边)。
|
||||
pub fn text<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Text, button)
|
||||
}
|
||||
|
||||
/// IconButton(standard / filled / tonal):40×40、图标 24、命中区 48。
|
||||
///
|
||||
/// `on_press: Option<M>`(`None` = 禁用)与 `state` 分开给:前者是语义,后者是取证开关。
|
||||
pub fn icon<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
variant: IconVariant,
|
||||
kind: IconKind,
|
||||
on_press: Option<M>,
|
||||
state: Option<Interaction>,
|
||||
) -> Element<'a, M> {
|
||||
let geometry = IconGeometry::of(ui);
|
||||
let (fill, content, border) = variant.roles();
|
||||
let forced = state.unwrap_or(if on_press.is_some() {
|
||||
Interaction::Enabled
|
||||
} else {
|
||||
Interaction::Disabled
|
||||
});
|
||||
|
||||
// Q9:图标**必须在固定尺寸的中层容器里**,否则 Button 的 Fill 约束会让 usvg 按 Contain
|
||||
// 撑满内容区(M68:24 的字形渲染成 46px、线宽 1.5 → 2.9)。
|
||||
// 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中,
|
||||
// 所以「居中」这件事必须由容器自己写死,别指望 button。
|
||||
let icon_box = layout::centered_fixed(
|
||||
geometry.side,
|
||||
geometry.side,
|
||||
icons::view::<M>(kind, ui.color(content)),
|
||||
);
|
||||
|
||||
let style = style_closure(
|
||||
ui,
|
||||
StyleSpec {
|
||||
fill,
|
||||
content,
|
||||
border,
|
||||
// 方形图标按钮同样是 full 圆角(40 → 20),档位取自组件表
|
||||
shape: Component::IconButton.spec().shape,
|
||||
radius_side: geometry.side,
|
||||
forced,
|
||||
},
|
||||
);
|
||||
|
||||
let element = button(icon_box)
|
||||
.width(Length::Fixed(geometry.side))
|
||||
.height(Length::Fixed(geometry.side))
|
||||
.padding(Padding::default())
|
||||
.style(style);
|
||||
|
||||
match on_press {
|
||||
Some(message) => element.on_press(message).into(),
|
||||
None => element.into(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! 按钮家族的单测(几何值 / 色彩角色 / 样式闭包)。
|
||||
//!
|
||||
//! 这里测的是「函数与样式结构」;**像素级**的几何(40 高、20 圆角、24 图标盒)
|
||||
//! 由 `gallery/probe.rs` 对着真截图量——M68 的教训是「函数对、图错」,两者缺一不可。
|
||||
|
||||
use iced::widget::button;
|
||||
use iced::{Background, Shadow};
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::elevation::Elevation;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::assemble::StyleSpec;
|
||||
use super::{style_closure, Geometry, IconGeometry, IconVariant, Variant};
|
||||
use crate::theme::{Families, Ui, STROKE_PX};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(
|
||||
flavor,
|
||||
unlucky_design::typography::Platform::Windows,
|
||||
FAMILIES,
|
||||
)
|
||||
}
|
||||
|
||||
fn light() -> Ui {
|
||||
ui(SchemeFlavor::Light)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Msg {
|
||||
Pressed(&'static str),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_variant_builds_and_disabled_has_no_message() {
|
||||
let ui = light();
|
||||
let _ = super::filled(&ui, super::Button::new("Filled", Msg::Pressed("f")));
|
||||
let _ = super::tonal(&ui, super::Button::new("Tonal", Msg::Pressed("t")));
|
||||
let _ = super::elevated(&ui, super::Button::new("Elevated", Msg::Pressed("e")));
|
||||
let _ = super::outlined(&ui, super::Button::new("Outlined", Msg::Pressed("o")));
|
||||
let _ = super::text(&ui, super::Button::new("Text", Msg::Pressed("x")));
|
||||
let _ = super::filled::<Msg>(&ui, super::Button::disabled("Disabled"));
|
||||
let _ = super::filled(
|
||||
&ui,
|
||||
super::Button::new("Icons", Msg::Pressed("i"))
|
||||
.leading(IconKind::Plus)
|
||||
.trailing(IconKind::ChevronRight),
|
||||
);
|
||||
}
|
||||
|
||||
/// 12b 1.1~1.6 + 2.1~2.2:几何逐值钉住(每个值都要能指回令牌)。
|
||||
#[test]
|
||||
fn geometry_matches_the_reference_values() {
|
||||
let ui = light();
|
||||
let geometry = Geometry::of(&ui, Variant::Filled, false);
|
||||
assert_eq!(geometry.height, 40.0, "12b 1.1 可见高 40");
|
||||
assert_eq!(geometry.height, ui.metrics().control_height);
|
||||
assert_eq!(
|
||||
ui.radius(unlucky_design::shape::ShapeScale::Full, geometry.height)
|
||||
.top_left,
|
||||
20.0,
|
||||
"12b 1.2 full 圆角 = 高/2"
|
||||
);
|
||||
assert_eq!(geometry.padding.left, 24.0, "12b 1.3 左 24");
|
||||
assert_eq!(geometry.padding.right, 24.0, "12b 1.3 右 24");
|
||||
assert_eq!(geometry.padding.top, 0.0);
|
||||
assert_eq!(geometry.gap, 8.0, "12b 1.6 图标↔文字 8");
|
||||
assert_eq!(geometry.gap, ui.space(Space::X2));
|
||||
assert_eq!(geometry.stroke, 0.0, "实心按钮无描边");
|
||||
|
||||
let with_icon = Geometry::of(&ui, Variant::Filled, true);
|
||||
assert_eq!(with_icon.padding.left, 16.0, "12b 1.4 带图标左内边距 16");
|
||||
assert_eq!(with_icon.padding.right, 24.0, "带图标右内边距不动");
|
||||
|
||||
let outlined = Geometry::of(&ui, Variant::Outlined, false);
|
||||
assert_eq!(outlined.stroke, STROKE_PX, "rubric G7 全站 1px");
|
||||
assert_eq!(outlined.stroke, 1.0);
|
||||
|
||||
// 图标 16 是**令牌值**;官方 18 是 G14 登记的偏离(不在这里假装相等)
|
||||
assert_eq!(geometry.icon, 16.0, "IconSize::Button");
|
||||
assert_eq!(geometry.height % 4.0, 0.0, "可见高必须在 4px 栅格上(D1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_button_geometry_matches_the_reference() {
|
||||
let ui = light();
|
||||
let geometry = IconGeometry::of(&ui);
|
||||
assert_eq!((geometry.side, geometry.icon), (40.0, 24.0), "12b 2.1/2.2");
|
||||
assert_eq!(
|
||||
ui.radius(unlucky_design::shape::ShapeScale::Full, geometry.side)
|
||||
.top_left,
|
||||
20.0,
|
||||
"40×40 的 full 圆角 = 20"
|
||||
);
|
||||
assert_eq!(geometry.padding, 8.0, "12b 2.2 默认边距 8");
|
||||
// 命中区 = 容器 40 + 四周各 4(12b 2.8);按钮自身不撑高
|
||||
assert_eq!(
|
||||
geometry.side + 2.0 * ui.space(Space::X1),
|
||||
ui.metrics().touch_target,
|
||||
"40 + 8 = 48 命中下限"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_variants_have_distinct_colour_roles() {
|
||||
let mut seen = Vec::new();
|
||||
for variant in Variant::ALL {
|
||||
let roles = variant.roles();
|
||||
assert!(
|
||||
!seen.contains(&roles),
|
||||
"{variant:?} 与别的变体色彩角色重复了"
|
||||
);
|
||||
seen.push(roles);
|
||||
}
|
||||
|
||||
// 12b 1.9 逐条角色名核对
|
||||
assert_eq!(
|
||||
Variant::Filled.roles(),
|
||||
(
|
||||
Some(unlucky_design::color::Role::Primary),
|
||||
unlucky_design::color::Role::OnPrimary,
|
||||
None
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Tonal.roles(),
|
||||
(
|
||||
Some(unlucky_design::color::Role::SecondaryContainer),
|
||||
unlucky_design::color::Role::OnSecondaryContainer,
|
||||
None
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Elevated.roles(),
|
||||
(
|
||||
Some(unlucky_design::color::Role::SurfaceContainerLow),
|
||||
unlucky_design::color::Role::Primary,
|
||||
None
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Outlined.roles(),
|
||||
(
|
||||
None,
|
||||
unlucky_design::color::Role::OnSurfaceVariant,
|
||||
Some(unlucky_design::color::Role::OutlineVariant)
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Text.roles(),
|
||||
(None, unlucky_design::color::Role::OnSurfaceVariant, None)
|
||||
);
|
||||
|
||||
for variant in Variant::ALL {
|
||||
let expected = if variant == Variant::Elevated {
|
||||
Elevation::Level1
|
||||
} else {
|
||||
Elevation::Level0
|
||||
};
|
||||
assert_eq!(variant.elevation(), expected, "{variant:?} 高程");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_button_variants_match_the_reference_roles() {
|
||||
use unlucky_design::color::Role;
|
||||
assert_eq!(
|
||||
IconVariant::Standard.roles(),
|
||||
(None, Role::OnSurfaceVariant, None)
|
||||
);
|
||||
assert_eq!(
|
||||
IconVariant::Filled.roles(),
|
||||
(Some(Role::Primary), Role::OnPrimary, None)
|
||||
);
|
||||
assert_eq!(
|
||||
IconVariant::Tonal.roles(),
|
||||
(
|
||||
Some(Role::SecondaryContainer),
|
||||
Role::OnSecondaryContainer,
|
||||
None
|
||||
)
|
||||
);
|
||||
|
||||
let ui = light();
|
||||
for variant in IconVariant::ALL {
|
||||
let _ = super::icon::<Msg>(&ui, variant, IconKind::Settings, None, None);
|
||||
let _ = super::icon(
|
||||
&ui,
|
||||
variant,
|
||||
IconKind::Settings,
|
||||
Some(Msg::Pressed("i")),
|
||||
None,
|
||||
);
|
||||
// 五态取证开关都能出元素(gallery 矩阵依赖它)
|
||||
for state in Interaction::ALL {
|
||||
let _ = super::icon(
|
||||
&ui,
|
||||
variant,
|
||||
IconKind::Settings,
|
||||
Some(Msg::Pressed("i")),
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spec_of(variant: Variant, forced: Interaction) -> StyleSpec {
|
||||
let (fill, content, border) = variant.roles();
|
||||
StyleSpec {
|
||||
fill,
|
||||
content,
|
||||
border,
|
||||
shape: variant.shape(),
|
||||
radius_side: Geometry::of(&light(), variant, false).height,
|
||||
forced,
|
||||
}
|
||||
}
|
||||
|
||||
/// **渲染层**:样式闭包吐给 iced 的圆角/描边/阴影必须是令牌推导值。
|
||||
#[test]
|
||||
fn outlined_style_draws_hairline_border_and_full_radius() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Outlined, Interaction::Enabled));
|
||||
|
||||
let idle = style(&ui.theme(), button::Status::Active);
|
||||
assert_eq!(idle.background, None, "Outlined 静息容器必须透明");
|
||||
assert_eq!(idle.border.width, STROKE_PX, "1dp 描边");
|
||||
assert_eq!(idle.border.radius.top_left, 20.0, "full 圆角");
|
||||
assert_eq!(
|
||||
idle.border.color,
|
||||
ui.color(unlucky_design::color::Role::OutlineVariant)
|
||||
);
|
||||
assert_eq!(idle.shadow, Shadow::default(), "L0 不许有阴影");
|
||||
|
||||
// 透明容器也要看得见 hover:状态层叠到表面色上
|
||||
let hovered = style(&ui.theme(), button::Status::Hovered);
|
||||
assert_ne!(hovered.background, None, "透明容器的 hover 必须有可见变化");
|
||||
assert_ne!(hovered.background, idle.background);
|
||||
|
||||
let disabled = style(&ui.theme(), button::Status::Disabled);
|
||||
let (container, content) =
|
||||
interaction::disabled_colors(&ui, unlucky_design::color::Role::Surface);
|
||||
assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%");
|
||||
assert_eq!(disabled.border.color, container, "禁用描边 = 令牌 12%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_button_state_layers_are_visible_and_ordered() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Filled, Interaction::Enabled));
|
||||
|
||||
let idle = style(&ui.theme(), button::Status::Active);
|
||||
let hovered = style(&ui.theme(), button::Status::Hovered);
|
||||
let pressed = style(&ui.theme(), button::Status::Pressed);
|
||||
|
||||
assert_eq!(
|
||||
idle.background,
|
||||
Some(Background::Color(
|
||||
ui.color(unlucky_design::color::Role::Primary)
|
||||
))
|
||||
);
|
||||
assert_ne!(hovered.background, idle.background, "hover 8%");
|
||||
assert_ne!(
|
||||
pressed.background, hovered.background,
|
||||
"pressed 10% ≠ hover 8%"
|
||||
);
|
||||
assert_eq!(
|
||||
hovered.text_color,
|
||||
ui.color(unlucky_design::color::Role::OnPrimary),
|
||||
"12b 2.4/4.7:状态层不换内容色"
|
||||
);
|
||||
assert_eq!(idle.border.width, 0.0, "实心按钮无描边");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_state_overrides_the_runtime_status() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Tonal, Interaction::Hovered));
|
||||
// 运行期说 Active,但取证要 hover → 必须画 hover
|
||||
assert_eq!(
|
||||
style(&ui.theme(), button::Status::Active).background,
|
||||
style(&ui.theme(), button::Status::Hovered).background
|
||||
);
|
||||
assert_ne!(
|
||||
style(&ui.theme(), button::Status::Active).background,
|
||||
Some(Background::Color(
|
||||
ui.color(unlucky_design::color::Role::SecondaryContainer)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elevated_uses_a_tone_not_a_shadow() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Elevated, Interaction::Enabled));
|
||||
let idle = style(&ui.theme(), button::Status::Active);
|
||||
assert_eq!(idle.shadow, Shadow::default(), "rubric §7:L1 不画阴影");
|
||||
assert_eq!(
|
||||
idle.background,
|
||||
Some(Background::Color(
|
||||
ui.color(unlucky_design::color::Role::SurfaceContainerLow)
|
||||
)),
|
||||
"L1 由 SurfaceContainerLow 的色调差表达"
|
||||
);
|
||||
assert_eq!(
|
||||
idle.text_color,
|
||||
ui.color(unlucky_design::color::Role::Primary)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! 分隔线(`research/15` §22,A1:`DividerTokens.kt` / `Divider.kt` / `ListTokens.kt`)。
|
||||
//!
|
||||
//! | # | 检查项 | 参考值 | 落点 |
|
||||
//! |---|---|---|---|
|
||||
//! | 22.1 | 厚度 | **1dp** → 1px ±0 @100% DPI | `LayoutMetrics::divider_thickness` = [`STROKE_PX`] |
|
||||
//! | 22.2 | 颜色 | **`OutlineVariant`**(不是 `Outline`,那个更深、会抢层级) | `Role::OutlineVariant` |
|
||||
//! | 22.3 | 缩进 | 列表内 **左 16 / 右 16**;区块分隔 **0(通栏)** | [`list`] / [`full`] |
|
||||
//! | 22.4 | 用量 | 同屏同类线 ≤3;同容器内不得既有描边又有分隔线 | 页面线自查(本模块不强制) |
|
||||
//! | 22.5 | 何时不用 | tone 能分层就不画线;纵向分隔本项目不用 | 文档约定 |
|
||||
//!
|
||||
//! ## 语义纪律(22.5 的一句话口径)
|
||||
//!
|
||||
//! **分隔线是兜底手段,不是排版手段**:能靠 `Surface` / `SurfaceContainerLow` /
|
||||
//! `SurfaceContainerHigh` 的色差把层级说清的地方就不画线;卡片已经有 1px `OutlineVariant`
|
||||
//! 描边时,卡片**之间**不再画线(同时出现 = 双重分隔,rubric K3 那类廉价感)。
|
||||
//!
|
||||
//! ## 为什么只有两个入口
|
||||
//!
|
||||
//! §22.3 只承认两种缩进:通栏(区块分隔)与列表内 16。第三种缩进一定是「页面自己想出来的」,
|
||||
//! 所以不提供任意数值入口——真要第三种,先在 `SubAgentsTalk/` 登记。
|
||||
|
||||
use iced::widget::{container, space};
|
||||
use iced::{Background, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::theme::{Ui, STROKE_PX};
|
||||
|
||||
use super::layout;
|
||||
|
||||
/// 通栏分隔线(区块分隔:0 缩进)。
|
||||
pub fn full<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
line(ui)
|
||||
}
|
||||
|
||||
/// 列表内分隔线:左缩进 `Space::X4`(16),右侧到底(`ListTokens.DividerLeadingSpace`)。
|
||||
///
|
||||
/// 右缩进按官方也是 16,但列表行本身已有 16 的内边距时再缩一次会变成 32——
|
||||
/// 本项目把「行内边距」交给行,本函数只负责**线相对行**的缩进,故右端通到行尾。
|
||||
pub fn list<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
layout::inset_start(ui.space(Space::X4), line(ui))
|
||||
}
|
||||
|
||||
/// 一条 1px 的横线(厚度/颜色都只有一处定义)。
|
||||
fn line<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
container(space::horizontal())
|
||||
.width(Length::Fill)
|
||||
.height(thickness(ui))
|
||||
.style(|_theme| container::Style {
|
||||
background: Some(Background::Color(ui.color(Role::OutlineVariant))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 线厚(= `LayoutMetrics::divider_thickness` = `stroke::HAIRLINE`,三者同一真源)。
|
||||
pub fn thickness(ui: &Ui) -> f32 {
|
||||
debug_assert_eq!(
|
||||
ui.metrics().divider_thickness,
|
||||
STROKE_PX,
|
||||
"分隔线厚度必须是一像素线的唯一真源"
|
||||
);
|
||||
ui.metrics().divider_thickness
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
fn ui() -> Ui {
|
||||
Ui::with_platform(
|
||||
SchemeFlavor::Light,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 22.1:厚度 1(不是 2,也不是 1.5)。
|
||||
#[test]
|
||||
fn thickness_is_exactly_one_pixel() {
|
||||
let ui = ui();
|
||||
assert_eq!(thickness(&ui), 1.0);
|
||||
assert_eq!(thickness(&ui), STROKE_PX);
|
||||
assert_eq!(thickness(&ui), ui.metrics().divider_thickness);
|
||||
}
|
||||
|
||||
/// 22.3:列表内缩进 16,来自 `Space::X4` 而不是字面量。
|
||||
#[test]
|
||||
fn list_inset_comes_from_the_spacing_token() {
|
||||
let ui = ui();
|
||||
assert_eq!(ui.space(Space::X4), 16.0, "ListTokens.DividerLeadingSpace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_forms_build() {
|
||||
let ui = ui();
|
||||
let _: Element<'_, ()> = full(&ui);
|
||||
let _: Element<'_, ()> = list(&ui);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! **反馈类家族(桩)**:由后续 mission 填充(§12 Progress / §14 Snackbar / §7 Dialog / §15 EmptyState)。
|
||||
//!
|
||||
//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**:
|
||||
//! 家族模块已经在 `mod.rs` 里声明好,`gallery/feedback.rs` 也已经在 `gallery/mod.rs` 里声明好,
|
||||
//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。
|
||||
//!
|
||||
//! 填充时请照抄按钮家族的分层:
|
||||
//!
|
||||
//! - 几何进 `feedback/geometry.rs`(数值从 `Space` / `ShapeScale` / `LayoutMetrics` 取,
|
||||
//! 缺令牌登记 G 编号——Dialog 的 280/560 宽、Snackbar 的 48/68 高目前都还没有令牌);
|
||||
//! - 状态层只能走 [`crate::app::components::interaction`];
|
||||
//! - Dialog/Snackbar 是**浮层**:阴影只在这两处允许(rubric K3),且单层。
|
||||
|
||||
use iced::widget::{container, text};
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 占位 demo(ASCII 文案,理由同 `selection::demo`)。
|
||||
pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text("feedback · progress / snackbar / dialog / empty state — pending")
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.padding(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! 按钮家族的状态矩阵(D30:浅/深 × 五态)。
|
||||
//!
|
||||
//! ## 矩阵形状
|
||||
//!
|
||||
//! ```text
|
||||
//! | enabled | hovered | pressed | focused | disabled |
|
||||
//! filled | [btn] | [btn] | [btn] | [btn] | [btn] |
|
||||
//! tonal | ... (下同)
|
||||
//! elevated |
|
||||
//! outlined |
|
||||
//! text |
|
||||
//! icon·standard | [ico] | ...
|
||||
//! icon·filled |
|
||||
//! icon·tonal |
|
||||
//! ```
|
||||
//!
|
||||
//! 40 个单元格共 5 列 × 8 行,一屏(1180×760)装不下,靠 `gallery/mod.rs` 的 scrollable 滚动;
|
||||
//! 截图取的是**整窗**,所以首屏能看到按钮家族的全部 5 行(图标行可能需要滚动,报告里会说明)。
|
||||
//!
|
||||
//! ## 为什么每格都强制状态
|
||||
//!
|
||||
//! iced 的 `button::Status` 只由**真实鼠标**驱动,截图时没法让 40 个按钮同时 hover。
|
||||
//! 组件层的 `Button::state(...)` 就是为这件事开的取证开关(`--force-state` 只能给整屏一种态)。
|
||||
//! 强制值**只影响画什么**,不动任何状态机——所以矩阵图里的五态与运行期五态逐像素同源。
|
||||
|
||||
use iced::widget::{column, row};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::buttons::{self, Button, IconVariant, Variant};
|
||||
use super::super::interaction::Interaction;
|
||||
use super::{cell_width, heading, row_label, state_cells, DemoMessage};
|
||||
|
||||
/// 按钮家族整段(标题 + 列头 + 5 行文字按钮 + 3 行图标按钮 + 一条图例说明)。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let legend = ui.style(TypeRole::BodySmall);
|
||||
|
||||
let mut rows = column![header_row(ui)].spacing(ui.space(Space::X2));
|
||||
|
||||
for variant in Variant::ALL {
|
||||
let cells = Interaction::ALL
|
||||
.iter()
|
||||
.map(|state| cell(ui, text_button(ui, variant, *state)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rows = rows.push(
|
||||
row![
|
||||
row_label(ui, variant.name()),
|
||||
row(cells).spacing(ui.space(Space::X2))
|
||||
]
|
||||
.spacing(ui.space(Space::X2)),
|
||||
);
|
||||
}
|
||||
|
||||
for variant in IconVariant::ALL {
|
||||
let cells = Interaction::ALL
|
||||
.iter()
|
||||
.map(|state| cell(ui, icon_button(ui, variant, *state)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rows = rows.push(
|
||||
row![
|
||||
row_label(ui, variant.name()),
|
||||
row(cells).spacing(ui.space(Space::X2))
|
||||
]
|
||||
.spacing(ui.space(Space::X2)),
|
||||
);
|
||||
}
|
||||
|
||||
column![
|
||||
heading(
|
||||
ui,
|
||||
"buttons · filled / tonal / elevated / outlined / text + icon",
|
||||
"12b §1/§2:高 40 · 圆角 full(20) · 内边距 24(带图标 16/24)· 图标 16[G14] 间距 8 · \
|
||||
hover 8% / focus 10% / pressed 10% / disabled 容器 12% 内容 38%"
|
||||
),
|
||||
rows,
|
||||
iced::widget::text(
|
||||
"图例:列 = 五态(focus 与 pressed 同 α 10%,看起来一样是规范要求);\
|
||||
行 = 变体。虚线不表示任何东西,只是矩阵栅格。"
|
||||
)
|
||||
.size(legend.size_px)
|
||||
.font(ui.font_of(legend))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 列头(五态名)。
|
||||
fn header_row<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let style = ui.style(TypeRole::LabelSmall);
|
||||
let mut head = row![row_label(ui, "")].spacing(ui.space(Space::X2));
|
||||
|
||||
for (name, width) in state_cells(ui) {
|
||||
head = head.push(
|
||||
iced::widget::container(
|
||||
iced::widget::text(name)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.width(Length::Fixed(width)),
|
||||
);
|
||||
}
|
||||
|
||||
head.into()
|
||||
}
|
||||
|
||||
/// 一个矩阵单元格(固定宽,内容左对齐;高度只由按钮自己决定)。
|
||||
fn cell<'a>(ui: &'a Ui, content: Element<'a, DemoMessage>) -> Element<'a, DemoMessage> {
|
||||
iced::widget::container(content)
|
||||
.width(Length::Fixed(cell_width(ui)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 文字按钮的一格(强制成 `state`)。
|
||||
fn text_button<'a>(ui: &'a Ui, variant: Variant, state: Interaction) -> Element<'a, DemoMessage> {
|
||||
// 标签用变体名 + 首字母大写:图上要能一眼对上左栏,且不引入 i18n 依赖(gallery 不是产品页)
|
||||
let label = match variant {
|
||||
Variant::Filled => "Filled",
|
||||
Variant::Tonal => "Tonal",
|
||||
Variant::Elevated => "Elevated",
|
||||
Variant::Outlined => "Outlined",
|
||||
Variant::Text => "Text",
|
||||
};
|
||||
|
||||
let lead = if variant == Variant::Text {
|
||||
Button::new(label, DemoMessage::Pressed("text"))
|
||||
} else {
|
||||
Button::new(label, DemoMessage::Pressed(label))
|
||||
.leading(IconKind::Play)
|
||||
.trailing(IconKind::ChevronRight)
|
||||
};
|
||||
|
||||
// 禁用格必须真的没有消息(语义正确),再强制成 Disabled 的画法
|
||||
let spec = if state.is_disabled() {
|
||||
Button::disabled(label)
|
||||
} else {
|
||||
lead
|
||||
}
|
||||
.state(state);
|
||||
|
||||
match variant {
|
||||
Variant::Filled => buttons::filled(ui, spec),
|
||||
Variant::Tonal => buttons::tonal(ui, spec),
|
||||
Variant::Elevated => buttons::elevated(ui, spec),
|
||||
Variant::Outlined => buttons::outlined(ui, spec),
|
||||
Variant::Text => buttons::text(ui, spec),
|
||||
}
|
||||
}
|
||||
|
||||
/// 图标按钮的一格(强制成 `state`)。
|
||||
fn icon_button<'a>(
|
||||
ui: &'a Ui,
|
||||
variant: IconVariant,
|
||||
state: Interaction,
|
||||
) -> Element<'a, DemoMessage> {
|
||||
let press = if state.is_disabled() {
|
||||
None
|
||||
} else {
|
||||
Some(DemoMessage::Pressed(variant.name()))
|
||||
};
|
||||
|
||||
buttons::icon(ui, variant, IconKind::Settings, press, Some(state))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! gallery 里的反馈类小节(**桩**):后续 mission 只改本文件。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::feedback;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 反馈类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"feedback · progress / snackbar / dialog / empty state",
|
||||
"12b §7/§12/§14/§15 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
feedback::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! gallery 里的输入类小节(**桩**):后续 mission 只改本文件。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::inputs;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 输入类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"inputs · text field / search / slider / menu",
|
||||
"12b §8/§9/§11 + 15 §20 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
inputs::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! `--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`)。
|
||||
|
||||
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::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 feedback;
|
||||
mod inputs;
|
||||
mod navigation;
|
||||
#[cfg(test)]
|
||||
mod probe;
|
||||
#[cfg(test)]
|
||||
mod scan;
|
||||
mod selection;
|
||||
|
||||
/// 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 · flavor={} · 1180×760 @100% DPI · \
|
||||
focus 与 pressed 同为 10%(令牌口径,图上一样是对的)",
|
||||
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 留白 + 标题。
|
||||
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);
|
||||
|
||||
let body = scrollable(sections)
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill);
|
||||
|
||||
// 页面留白走 `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(super) 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` 的口径)。
|
||||
///
|
||||
/// 列宽 = 按钮最常见的自然宽 132(用作矩阵栅格,不是组件尺寸)。
|
||||
pub(super) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
|
||||
Interaction::ALL
|
||||
.iter()
|
||||
.map(|state| (state.name(), cell_width(ui)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 矩阵单元格宽度(只影响 harness 的取景,不是组件尺寸;216 = 5 列正好铺满 1092 内容宽)。
|
||||
pub(super) fn cell_width(ui: &Ui) -> f32 {
|
||||
ui.space(Space::X8) * 6.0
|
||||
}
|
||||
|
||||
/// 一个矩阵行的左侧标签(变体名)。
|
||||
pub(super) 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()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! gallery 里的导航类小节(**桩**):后续 mission 只改本文件。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::navigation;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 导航类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"navigation · list item / tabs / drawer / app bar",
|
||||
"12b §4/§6/§16 + 15 §21 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
navigation::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! 渲染层几何断言:**对着真截图量像素**(M68 的教训:纯函数返回对,图可能是错的)。
|
||||
//!
|
||||
//! `buttons::tests` 只能证明「几何结构体里的数字是 40/20/24」——M68 的药丸正是这样:单测全绿、
|
||||
//! 图上是 88×32(容器被 `center_x(Length::Fill)` 覆盖)。唯一能抓住这类错误的是**真渲染的像素**。
|
||||
//! CI 没有桌面,所以这里不自己开窗:先用 `--screen=gallery --screenshot` 出真窗截图,本测试读
|
||||
//! 那张 PNG 量几何、与令牌逐项比对。**没给图就跳过并大声说出来**(不静默通过)。
|
||||
//!
|
||||
//! 复现命令(在 worktree 根目录):
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo build -p unlucky-app-iced --bin unlucky-launcher-iced
|
||||
//! ./target/debug/unlucky-launcher-iced --software-renderer --screen=gallery //! --screenshot target/m75/gallery-light.png --screenshot-theme=light
|
||||
//! ULL_GALLERY_SHOT=target/m75/gallery-light.png cargo test -p unlucky-app-iced gallery::probe
|
||||
//! ```
|
||||
//!
|
||||
//! 量什么(每条对一个已发生过的错误):实心块高 **40**(12b 1.1)· 药丸顶行宽 ≈9 ⇒ 圆角 full=20
|
||||
//! (12b 1.2,8 圆角会接近通栏)· IconButton **40×40**(12b 2.1)· 字形墨迹 **16~26**
|
||||
//! (Q9:24 盒被填满会到 40)· 分隔线 **1px**(22.1)· 禁用容器 **on-surface@12%** 存在(12b 1.8)。
|
||||
|
||||
use unlucky_design::color::{Role, SchemeFlavor};
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
use super::scan::{matches, nearer, rgb, run_at, Shot};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
/// 一条测试覆盖全部几何断言(每张图各跑一次)。
|
||||
#[test]
|
||||
fn gallery_geometry_matches_the_tokens() {
|
||||
let Some(path) = shot_path() else {
|
||||
eprintln!(
|
||||
"[probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\
|
||||
需要先出一张真窗截图(命令见本文件模块文档)。"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let shot = Shot::load(&path).expect("读 gallery 截图");
|
||||
assert_eq!(
|
||||
(shot.width, shot.height),
|
||||
(1180, 760),
|
||||
"验收图必须是 D30 的 1180×760 @100% DPI"
|
||||
);
|
||||
|
||||
// 用左上角背景色判定深浅(页面 padding 区就是 `Surface`)
|
||||
let corner = shot.at(2, 2);
|
||||
let flavor = if matches(corner, rgb(ui(SchemeFlavor::Light).color(Role::Surface))) {
|
||||
SchemeFlavor::Light
|
||||
} else {
|
||||
SchemeFlavor::Dark
|
||||
};
|
||||
let ui = ui(flavor);
|
||||
|
||||
// ---- 12b 1.1 / 1.2:实心按钮 40 高、药丸圆角 20 ------------------------------
|
||||
let primary = shot.bbox_of(rgb(ui.color(Role::Primary)));
|
||||
assert!(
|
||||
primary.len() >= 2,
|
||||
"至少该找到 Filled 按钮与 Filled IconButton 两块实心(实际 {})",
|
||||
primary.len()
|
||||
);
|
||||
|
||||
let mut label_button = None;
|
||||
for bbox in &primary {
|
||||
assert!(
|
||||
(39..=41).contains(&bbox.height()),
|
||||
"实心块高 {}(参考 40,12b 1.1)@ {:?}",
|
||||
bbox.height(),
|
||||
(bbox.left, bbox.top)
|
||||
);
|
||||
if bbox.width() > 60 {
|
||||
label_button = Some(*bbox);
|
||||
}
|
||||
if (39..=41).contains(&bbox.width()) {
|
||||
// 12b 2.1:IconButton 容器 40×40
|
||||
assert!(
|
||||
(39..=41).contains(&bbox.width()),
|
||||
"IconButton 应为 40×40,实测 {}×{}",
|
||||
bbox.width(),
|
||||
bbox.height()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let label_button = label_button.expect("没找到文字按钮的实心块");
|
||||
assert!(
|
||||
label_button.width() >= 58,
|
||||
"12b 1.7:最小宽 58,实测 {}",
|
||||
label_button.width()
|
||||
);
|
||||
|
||||
// 含抗锯齿的真实外框:中心列上「更接近填充色而不是底色」的连续行
|
||||
let fill = rgb(ui.color(Role::Primary));
|
||||
let surface = rgb(ui.color(Role::Surface));
|
||||
let center = (label_button.left + label_button.right) / 2;
|
||||
let mut true_top = label_button.top;
|
||||
while true_top > 0 && nearer(shot.at(center, true_top - 1), fill, surface) {
|
||||
true_top -= 1;
|
||||
}
|
||||
let mut true_bottom = label_button.bottom;
|
||||
while true_bottom + 1 < shot.height && nearer(shot.at(center, true_bottom + 1), fill, surface) {
|
||||
true_bottom += 1;
|
||||
}
|
||||
let true_height = true_bottom - true_top + 1;
|
||||
|
||||
// 圆角档位:**量顶部那一行的宽度**。药丸(r = 高/2)顶部只有几像素宽;
|
||||
// 若半径是 8(不是 full),顶行会是「几乎通栏」。这条比「数圆角行数」抗锯齿得多。
|
||||
let top_run = run_at(
|
||||
&shot,
|
||||
label_button.top,
|
||||
label_button.left,
|
||||
label_button.right,
|
||||
rgb(ui.color(Role::Primary)),
|
||||
);
|
||||
assert!(
|
||||
(39..=41).contains(&true_height),
|
||||
"可见高应 40(12b 1.1),实测 {true_height}"
|
||||
);
|
||||
assert!(
|
||||
(4..=20).contains(&top_run),
|
||||
"药丸圆角应 = 20(12b 1.2,顶部一行的填充宽约 sqrt(2*20*1)*2 ≈ 9),实测 {top_run}px; 若接近整宽说明半径是 8 而不是 full"
|
||||
);
|
||||
|
||||
// ---- Q9:IconButton 的字形不许撑满 40 盒 --- // ---- Q9:IconButton 的字形不许撑满 40 盒 -----------------------------------
|
||||
let tonal = rgb(ui.color(Role::SecondaryContainer));
|
||||
let tonal_boxes = shot.bbox_of(tonal);
|
||||
let icon_states = tonal_boxes
|
||||
.iter()
|
||||
.filter(|bbox| (39..=41).contains(&bbox.width()) && (39..=41).contains(&bbox.height()))
|
||||
.count();
|
||||
assert!(
|
||||
icon_states >= 2,
|
||||
"tonal IconButton 至少 2 个(实际 {icon_states})"
|
||||
);
|
||||
|
||||
let gear = rgb(ui.color(Role::OnSecondaryContainer));
|
||||
let mut measured_glyph = false;
|
||||
let mut glyph_width = 0;
|
||||
for bbox in tonal_boxes
|
||||
.iter()
|
||||
.filter(|bbox| (39..=41).contains(&bbox.width()))
|
||||
{
|
||||
if let Some(ink) = shot.ink_inside(bbox, gear) {
|
||||
let width = ink.width();
|
||||
if width > glyph_width {
|
||||
glyph_width = width;
|
||||
}
|
||||
assert!(
|
||||
(16..=26).contains(&width),
|
||||
"图标盒 24 的字形墨迹应 16~26 宽,实测 {width}(Q9 回归时会接近 40)"
|
||||
);
|
||||
measured_glyph = true;
|
||||
}
|
||||
}
|
||||
assert!(measured_glyph, "没量到 IconButton 的字形");
|
||||
|
||||
// ---- 22.1:分隔线 1px -----------------------------------------------------
|
||||
let outline = rgb(ui.color(Role::OutlineVariant));
|
||||
let mut runs = 0;
|
||||
for y in 0..shot.height {
|
||||
let mut longest = 0;
|
||||
let mut current = 0;
|
||||
for x in 0..shot.width {
|
||||
if matches(shot.at(x, y), outline) {
|
||||
current += 1;
|
||||
longest = longest.max(current);
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
if longest > 300 {
|
||||
runs += 1;
|
||||
}
|
||||
}
|
||||
// 分隔线是 1px 高,所以「长横线」的行数 = 屏上分隔线条数(每条约 1 行)
|
||||
assert!(runs >= 2, "至少该有 2 条分隔线(实际 {runs} 行)");
|
||||
assert!(
|
||||
runs <= 4,
|
||||
"分隔线最多 2 条 × 1px,实测 {runs} 行(22.1:大于这个数说明线不是 1px)"
|
||||
);
|
||||
|
||||
// ---- 12b 1.8:禁用格必须与静息格不同 ------------------------------------
|
||||
let disabled_container =
|
||||
crate::app::components::interaction::disabled_colors(&ui, Role::Surface).0;
|
||||
assert!(
|
||||
shot.bbox_of(rgb(disabled_container))
|
||||
.iter()
|
||||
.any(|b| b.width() > 60),
|
||||
"没找到禁用态实心按钮(12b 1.8:容器 = on-surface@12%)"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"[probe] OK flavor={:?}:
|
||||
Filled 药丸 {}×{}(参考 高 40 / 宽 ≥58)
|
||||
顶行填充宽 {}(full 圆角应为个位数,直角/8 圆角会接近整宽)
|
||||
实心块 {} 个(含 40×40 IconButton)
|
||||
分隔线长行 {} 行(每条 1px)
|
||||
禁用容器 (on-surface@12%) 命中
|
||||
字形墨迹 {}px(24 盒应 16~26)",
|
||||
flavor,
|
||||
label_button.width(),
|
||||
true_height,
|
||||
top_run,
|
||||
primary.len(),
|
||||
runs,
|
||||
glyph_width
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! 截图像素扫描工具(`gallery::probe` 的底层,四个后续家族的 probe 也能复用)。
|
||||
//!
|
||||
//! 只做「按颜色找连通块 / 量外接框 / 量某行的连续长度」三件事,不含任何判据——
|
||||
//! 判据在同级 `probe.rs` 里,这样每条家族线可以自带 probe 而不用重写扫描逻辑。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use iced::Color;
|
||||
|
||||
/// 颜色比对的通道容差(抗锯齿/取整)。
|
||||
const TOLERANCE: u8 = 10;
|
||||
|
||||
/// iced 颜色 → 8bit RGB(截图里的原值)。
|
||||
pub(super) fn rgb(color: Color) -> [u8; 3] {
|
||||
[
|
||||
(color.r * 255.0).round() as u8,
|
||||
(color.g * 255.0).round() as u8,
|
||||
(color.b * 255.0).round() as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// 颜色是否落在容差内。
|
||||
pub(super) fn matches(pixel: [u8; 3], expected: [u8; 3]) -> bool {
|
||||
pixel
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(a, b)| a.abs_diff(b) <= TOLERANCE)
|
||||
}
|
||||
|
||||
/// 一张图的像素访问器。
|
||||
pub(super) struct Shot {
|
||||
pub(super) width: usize,
|
||||
pub(super) height: usize,
|
||||
pixels: Vec<[u8; 3]>,
|
||||
}
|
||||
|
||||
impl Shot {
|
||||
pub(super) fn load(path: &Path) -> Result<Self, String> {
|
||||
let bytes = std::fs::read(path).map_err(|error| format!("读不动 {path:?}:{error}"))?;
|
||||
let image = image::load_from_memory(&bytes)
|
||||
.map_err(|error| format!("解 PNG 失败:{error}"))?
|
||||
.to_rgb8();
|
||||
let (width, height) = (image.width() as usize, image.height() as usize);
|
||||
Ok(Self {
|
||||
width,
|
||||
height,
|
||||
pixels: image.pixels().map(|p| p.0).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn at(&self, x: usize, y: usize) -> [u8; 3] {
|
||||
self.pixels[y * self.width + x]
|
||||
}
|
||||
|
||||
/// 某种颜色的全部像素的外接框(连通块)。
|
||||
pub(super) fn bbox_of(&self, color: [u8; 3]) -> Vec<BBox> {
|
||||
let mut seen = vec![false; self.width * self.height];
|
||||
let mut boxes = Vec::new();
|
||||
|
||||
for y in 0..self.height {
|
||||
for x in 0..self.width {
|
||||
if seen[y * self.width + x] || !matches(self.at(x, y), color) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut stack = vec![(x, y)];
|
||||
seen[y * self.width + x] = true;
|
||||
let mut bbox = BBox::new(x, y);
|
||||
|
||||
while let Some((cx, cy)) = stack.pop() {
|
||||
bbox.include(cx, cy);
|
||||
for (nx, ny) in [
|
||||
(cx + 1, cy),
|
||||
(cx.saturating_sub(1), cy),
|
||||
(cx, cy + 1),
|
||||
(cx, cy.saturating_sub(1)),
|
||||
] {
|
||||
let inside = nx < self.width && ny < self.height;
|
||||
if inside && !seen[ny * self.width + nx] && matches(self.at(nx, ny), color)
|
||||
{
|
||||
seen[ny * self.width + nx] = true;
|
||||
stack.push((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bbox.area() > 200 {
|
||||
boxes.push(bbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boxes
|
||||
}
|
||||
|
||||
/// `bbox` 内某颜色的墨迹外接框(量字形用)。
|
||||
///
|
||||
/// 内缩 3px:不内缩会把按钮自身的抗锯齿边算成墨迹(实测把 24 盒字形量成 40)。
|
||||
pub(super) fn ink_inside(&self, bbox: &BBox, color: [u8; 3]) -> Option<BBox> {
|
||||
const MARGIN: usize = 3;
|
||||
let mut ink: Option<BBox> = None;
|
||||
for y in (bbox.top + MARGIN)..=(bbox.bottom - MARGIN) {
|
||||
for x in (bbox.left + MARGIN)..=(bbox.right - MARGIN) {
|
||||
if matches(self.at(x, y), color) {
|
||||
match &mut ink {
|
||||
Some(found) => found.include(x, y),
|
||||
None => ink = Some(BBox::new(x, y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ink
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个外接框(闭区间)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub(super) struct BBox {
|
||||
pub(super) left: usize,
|
||||
pub(super) right: usize,
|
||||
pub(super) top: usize,
|
||||
pub(super) bottom: usize,
|
||||
}
|
||||
|
||||
impl BBox {
|
||||
pub(super) fn new(x: usize, y: usize) -> Self {
|
||||
Self {
|
||||
left: x,
|
||||
right: x,
|
||||
top: y,
|
||||
bottom: y,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn include(&mut self, x: usize, y: usize) {
|
||||
self.left = self.left.min(x);
|
||||
self.right = self.right.max(x);
|
||||
self.top = self.top.min(y);
|
||||
self.bottom = self.bottom.max(y);
|
||||
}
|
||||
|
||||
pub(super) fn width(&self) -> usize {
|
||||
self.right - self.left + 1
|
||||
}
|
||||
|
||||
pub(super) fn height(&self) -> usize {
|
||||
self.bottom - self.top + 1
|
||||
}
|
||||
|
||||
pub(super) fn area(&self) -> usize {
|
||||
self.width() * self.height()
|
||||
}
|
||||
}
|
||||
|
||||
/// `pixel` 更接近 `fill` 还是 `surface`。
|
||||
pub(super) fn nearer(pixel: [u8; 3], fill: [u8; 3], surface: [u8; 3]) -> bool {
|
||||
let dist = |a: [u8; 3], b: [u8; 3]| -> u32 {
|
||||
a.iter()
|
||||
.zip(b)
|
||||
.map(|(x, y)| (i32::from(*x) - i32::from(y)).unsigned_abs())
|
||||
.sum()
|
||||
};
|
||||
dist(pixel, fill) < dist(pixel, surface)
|
||||
}
|
||||
|
||||
/// 某一行的水平区间 `[left, right]` 里,`color` 连续像素的最大长度(量药丸顶部宽度用)。
|
||||
pub(super) fn run_at(shot: &Shot, y: usize, left: usize, right: usize, color: [u8; 3]) -> usize {
|
||||
let (mut longest, mut current) = (0, 0);
|
||||
for x in left..=right {
|
||||
if matches(shot.at(x, y), color) {
|
||||
current += 1;
|
||||
longest = longest.max(current);
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
longest
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! gallery 里的选择类小节(**桩**):后续 mission 只改本文件。
|
||||
//!
|
||||
//! 现在直接转调 `components::selection::demo`,把该家族的占位塞进五段结构里;
|
||||
//! 填充时把 `demo(ui)` 换成本家族的状态矩阵即可(列头/单元格工具见 `gallery/mod.rs`)。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::selection;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 选择类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"selection · checkbox / radio / switch / chip",
|
||||
"12b §13/§10 + 15 §18/§19 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
selection::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! 组件目录的自检门禁(`src/components/**`)。
|
||||
//!
|
||||
//! 为什么要有这份:`tests/no_hardcoded_tokens.rs` 只扫**色值**和 `shell/` 的尺寸字面量,
|
||||
//! 组件库目录是后来才开的——如果不管,五条并行线会各自写回 `.width(24.0)` 与
|
||||
//! `center_x(Length::Fill)`,M68 那两个形态级错误就会被复制五份。
|
||||
//!
|
||||
//! 四条判据(每条对应一个已发生过的错误):
|
||||
//!
|
||||
//! | 判据 | 抓的是什么 | 历史 |
|
||||
//! |---|---|---|
|
||||
//! | Q8:禁 `center_x(` / `center_y(` / `.center(` | 容器尺寸被 `Fill` 静默覆盖 | 药丸 56×32 → 88×32 |
|
||||
//! | Q9:图标必须经 `layout::fixed` / `centered_fixed` | 字形被 Button 的 Fill 撑满 | 24 的字形渲染成 46px |
|
||||
//! | T2:禁尺寸字面量 | 组件里重复定义令牌值 | rubric T2 |
|
||||
//! | 400 行 | 单文件上限(AGENTS.md §3-1) | `guards.yml` 的 file-size job |
|
||||
//!
|
||||
//! 扫描对象是**源码文本**而不是布局结果——这不是偷懒:布局结果要真窗口(CI 无桌面),
|
||||
//! 而「把尺寸交给 `center_*`」这个错误 100% 出现在源码形态里(M68 就是这样被抓的)。
|
||||
//! 像素级几何另有 `gallery::probe` 对着真截图量。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 单文件行数上限(AGENTS.md §3-1)。
|
||||
const MAX_LINES: usize = 400;
|
||||
|
||||
fn components_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src")
|
||||
.join("components")
|
||||
}
|
||||
|
||||
fn rust_files(dir: &Path, collected: &mut Vec<PathBuf>) {
|
||||
let entries =
|
||||
std::fs::read_dir(dir).unwrap_or_else(|error| panic!("读不动 {}:{error}", dir.display()));
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.expect("目录项读失败").path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, collected);
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
collected.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部组件源文件(`mod.rs` 也要扫:它是共享地基,最容易混进字面量)。
|
||||
fn sources() -> Vec<(PathBuf, String)> {
|
||||
let mut files = Vec::new();
|
||||
rust_files(&components_dir(), &mut files);
|
||||
files.sort();
|
||||
|
||||
assert!(
|
||||
files.len() >= 15,
|
||||
"只扫到 {} 个组件源文件:扫描逻辑坏了,门禁等于没跑",
|
||||
files.len()
|
||||
);
|
||||
|
||||
files
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|error| panic!("读不动 {}:{error}", path.display()));
|
||||
(path, text)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 去掉 `#[cfg(test)]` 之后的全部内容:测试里写参考值是**应该的**(12b 的 40/24/20
|
||||
/// 必须在测试里以字面量出现,否则测不出偏离)。
|
||||
fn product_part(text: &str) -> &str {
|
||||
match text.find("#[cfg(test)]") {
|
||||
Some(at) => &text[..at],
|
||||
None => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// 代码行(跳过注释行:文档里写 `.center_x(` 是在解释坑,不该判违规)。
|
||||
fn code_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
|
||||
text.lines().enumerate().filter_map(|(index, line)| {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("//") {
|
||||
None
|
||||
} else {
|
||||
Some((index + 1, line))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q8_no_center_shortcuts_that_swallow_fixed_sizes() {
|
||||
let mut offenders = Vec::new();
|
||||
|
||||
for (path, text) in sources() {
|
||||
for (line_no, line) in code_lines(product_part(&text)) {
|
||||
for pattern in ["center_x(", "center_y(", ".center("] {
|
||||
if line.contains(pattern) {
|
||||
offenders.push(format!(
|
||||
"{}:{line_no} {line}\n → {pattern} 会把容器尺寸改成 Fill(Q8),\
|
||||
居中请用父级 align_x 或 layout::centered_fixed",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(offenders.is_empty(), "Q8 违规:\n{}", offenders.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q9_icons_never_reach_a_button_unwrapped() {
|
||||
let mut offenders = Vec::new();
|
||||
|
||||
for (path, text) in sources() {
|
||||
let product = product_part(&text);
|
||||
// 允许两种写法:同一行里已经包在 layout:: 里;或它是 `layout::fixed(` 多行调用的实参
|
||||
// (图标常作为第 3 个参数出现在后两行,所以往回看 4 行)。
|
||||
let mut window: Vec<&str> = Vec::new();
|
||||
for (line_no, line) in code_lines(product) {
|
||||
if line.contains("icons::view") {
|
||||
let wrapped =
|
||||
line.contains("layout::fixed") || line.contains("layout::centered_fixed");
|
||||
let continued = window.iter().rev().any(|prev| {
|
||||
prev.contains("layout::fixed") || prev.contains("layout::centered_fixed")
|
||||
});
|
||||
if !wrapped && !continued {
|
||||
offenders.push(format!(
|
||||
"{}:{line_no} {line}\n → 图标必须经 layout::fixed / centered_fixed 包一层(Q9)",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
if !line.trim().is_empty() {
|
||||
window.push(line);
|
||||
if window.len() > 4 {
|
||||
window.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(offenders.is_empty(), "Q9 违规:\n{}", offenders.join("\n"));
|
||||
}
|
||||
|
||||
/// 布局调用后面直接跟数字 → 判违规(`0` 与 `0.0` 放行:0 = 无内边距/无描边)。
|
||||
fn size_literal(argument: &str) -> Option<String> {
|
||||
let mut rest = argument.trim_start();
|
||||
for wrapper in ["Length::Fixed(", "Padding::from(", "Size::new("] {
|
||||
if let Some(after) = rest.strip_prefix(wrapper) {
|
||||
rest = after.trim_start().trim_start_matches(['[', '(']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let number: String = rest
|
||||
.trim_start()
|
||||
.chars()
|
||||
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
|
||||
.collect();
|
||||
|
||||
if number.is_empty() || number.trim_end_matches('.').parse::<f64>() == Ok(0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(number)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2_no_size_literals_in_components() {
|
||||
let mut offenders = Vec::new();
|
||||
|
||||
for (path, text) in sources() {
|
||||
for (line_no, line) in code_lines(product_part(&text)) {
|
||||
for pattern in [".width(", ".height(", ".size(", ".spacing(", ".padding("] {
|
||||
let mut from = 0;
|
||||
while let Some(found) = line[from..].find(pattern) {
|
||||
let after = from + found + pattern.len();
|
||||
if let Some(number) = size_literal(&line[after..]) {
|
||||
offenders.push(format!(
|
||||
"{}:{line_no} {line} -> {pattern} 后面写了数字 {number}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
from = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"组件里不许有尺寸字面量(走 LayoutMetrics / Space / ComponentMetrics):\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_component_file_stays_under_the_line_limit() {
|
||||
let mut offenders = Vec::new();
|
||||
for (path, text) in sources() {
|
||||
let lines = text.lines().count();
|
||||
if lines > MAX_LINES {
|
||||
offenders.push(format!(
|
||||
"{}: {lines} 行(上限 {MAX_LINES})",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"单文件 ≤400 行(AGENTS.md §3-1,guards.yml 会判红):\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// 四个家族桩必须**只有桩**(不许顺手把别人的组件写了)。
|
||||
///
|
||||
/// 判据:文件里 `pub fn` 只允许出现一次,且它是 `demo`。
|
||||
#[test]
|
||||
fn the_four_family_stubs_are_still_stubs() {
|
||||
for family in ["selection", "inputs", "feedback", "navigation"] {
|
||||
let path = components_dir().join(family).join("mod.rs");
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|error| panic!("家族桩 {} 不见了:{error}", path.display()));
|
||||
let product = product_part(&text);
|
||||
|
||||
let pub_fns: Vec<&str> = product
|
||||
.lines()
|
||||
.filter(|line| line.trim_start().starts_with("pub fn "))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
pub_fns.len(),
|
||||
1,
|
||||
"{} 只该有占位 demo(),实际有 {} 个 pub fn:{pub_fns:?}(别顺手实现别人的家族)",
|
||||
path.display(),
|
||||
pub_fns.len()
|
||||
);
|
||||
assert!(
|
||||
pub_fns[0].contains("fn demo"),
|
||||
"{} 里唯一那个 pub fn 应该是 demo,实际是 {:?}",
|
||||
path.display(),
|
||||
pub_fns[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// gallery 的五个家族文件必须都在(`mod.rs` 声明了它们,缺一个就编译不过——
|
||||
/// 这条在并行线删文件/改名时给出人话,而不是一堆 `unresolved module`)。
|
||||
#[test]
|
||||
fn gallery_keeps_one_file_per_family() {
|
||||
for family in ["buttons", "selection", "inputs", "feedback", "navigation"] {
|
||||
let path = components_dir()
|
||||
.join("gallery")
|
||||
.join(format!("{family}.rs"));
|
||||
assert!(
|
||||
path.is_file(),
|
||||
"gallery 缺 {}(本文件结构是并行地基)",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let text = std::fs::read_to_string(&path).expect("读 gallery 家族文件");
|
||||
assert!(
|
||||
text.contains("fn section"),
|
||||
"{} 必须提供 section()(gallery/mod.rs 调它)",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 门禁自身不能空转:造几个违规样本喂给判据。
|
||||
#[test]
|
||||
fn the_gate_catches_planted_violations() {
|
||||
assert_eq!(
|
||||
size_literal("Length::Fixed(40.0))").as_deref(),
|
||||
Some("40.0")
|
||||
);
|
||||
assert_eq!(size_literal("0)").as_deref(), None);
|
||||
assert_eq!(
|
||||
size_literal("ui.metrics().control_height)").as_deref(),
|
||||
None
|
||||
);
|
||||
assert!(product_part("a\n#[cfg(test)]\n.width(40.0)\n")
|
||||
.find(".width(40.0)")
|
||||
.is_none());
|
||||
assert!(product_part("a\n.width(40.0)\n")
|
||||
.find(".width(40.0)")
|
||||
.is_some());
|
||||
// Q8 判据本身
|
||||
for code in ["x.center_x(Length::Fill)", "x.center(Length::Fill)"] {
|
||||
assert!(
|
||||
code.contains("center_x(") || code.contains(".center("),
|
||||
"判据写坏了"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! **输入类家族(桩)**:由后续 mission 填充(§9 TextField / §20 Search / §11 Slider / §8 Menu)。
|
||||
//!
|
||||
//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**:
|
||||
//! 家族模块已经在 `mod.rs` 里声明好,`gallery/inputs.rs` 也已经在 `gallery/mod.rs` 里声明好,
|
||||
//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。
|
||||
//!
|
||||
//! 填充时请照抄按钮家族的分层:
|
||||
//!
|
||||
//! - 几何进 `inputs/geometry.rs`(数值从 `ComponentMetrics::SEARCH_BAR` / `Space` / `ShapeScale` 取,
|
||||
//! 缺令牌登记 G 编号);
|
||||
//! - 状态层只能走 [`crate::app::components::interaction`](α 一处定义);
|
||||
//! - 精确尺寸用 [`crate::app::components::layout`](Q8/Q9 的解法),别手写 `.width(x)`;
|
||||
//! - 受控组件签名收 `impl Fn(String) -> M`(契约 §2),组件内不持有 `String`。
|
||||
|
||||
use iced::widget::{container, text};
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 占位 demo(ASCII 文案,理由同 `selection::demo`)。
|
||||
pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text("inputs · text field / search / slider / menu — pending")
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.padding(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! 交互状态层:hover / focus / pressed / disabled 的**唯一实现点**(D39 §3)。
|
||||
//!
|
||||
//! ## 为什么必须只有一处
|
||||
//!
|
||||
//! M3 的状态层不是「另一种灰」,而是**内容色自身 + 固定 alpha** 叠在容器色上
|
||||
//! (`unlucky_design::color::state` 的文档)。五个家族各写一遍
|
||||
//! `if hovered { 0.08 }`,第一周就会出现 0.08 / 0.1 / 0.12 三种 hover——
|
||||
//! 这一层存在的意义就是让「组件里出现 α 字面量」变成不可能:
|
||||
//!
|
||||
//! | 态 | α | 令牌来源 |
|
||||
//! |---|---|---|
|
||||
//! | hover | 0.08 | [`StateLayer::Hover`] |
|
||||
//! | focus | 0.10 | [`StateLayer::Focus`] |
|
||||
//! | pressed | 0.10 | [`StateLayer::Pressed`] |
|
||||
//! | dragged | 0.16 | [`StateLayer::Dragged`](按钮家族不用,列表拖拽用) |
|
||||
//! | disabled 容器 | 0.12 | [`DISABLED_CONTAINER_ALPHA`] |
|
||||
//! | disabled 内容 | 0.38 | [`DISABLED_CONTENT_ALPHA`] |
|
||||
//!
|
||||
//! 上表右列是**唯一**允许的取值口;左列只是给人看的,代码里一个数字都不许出现
|
||||
//! (`components::gate` 会扫)。
|
||||
//!
|
||||
//! ## 两个消费形态
|
||||
//!
|
||||
//! 1. **iced `button` 系**(本 mission 的按钮家族):拿 [`Interaction`] 直接配 [`overlay_color`];
|
||||
//! 2. **自定义 `Widget`**(后续若需要自绘轨道/滑块):用 [`Tracker`] 把事件折成五态,
|
||||
//! `update()` 的返回值告诉你「要不要重绘」,`state()` 给出当前态。
|
||||
//!
|
||||
//! ## 与 `Ui::state_over` 的关系
|
||||
//!
|
||||
//! 颜色一律经 [`crate::theme::Ui::state_over`] 求值(它返回**不透明**结果,所以 GPU/CPU 两档
|
||||
//! 逐像素一致,D27)。本模块只做「哪一态叠哪一层」的映射,不做任何颜色算术。
|
||||
|
||||
use iced::widget::button;
|
||||
use iced::{Color, Event};
|
||||
|
||||
use unlucky_design::color::state::{disabled_content, DISABLED_CONTAINER_ALPHA};
|
||||
use unlucky_design::color::{Role, Scheme, StateLayer};
|
||||
|
||||
use crate::theme::{to_iced, Ui};
|
||||
|
||||
/// 一个控件的视觉态(五态,M3 的完整集合)。
|
||||
///
|
||||
/// `Enabled` 是**静息态**(不叠状态层);`Focused` / `Pressed` 在 M3 里 α 相同(都 10%),
|
||||
/// 所以两者在图上会一模一样——这是规范,不是漏画(12b §1.8 判据只要求 α 对得上)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Interaction {
|
||||
/// 静息:可交互,不叠状态层。
|
||||
Enabled,
|
||||
/// 悬停:叠 8%。
|
||||
Hovered,
|
||||
/// 键盘焦点:叠 10%。
|
||||
Focused,
|
||||
/// 按下:叠 10%。
|
||||
Pressed,
|
||||
/// 禁用:容器 12% / 内容 38%,**不叠状态层**。
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl Interaction {
|
||||
/// 五态全序(gallery 的状态矩阵按这个次序出图)。
|
||||
pub const ALL: [Interaction; 5] = [
|
||||
Interaction::Enabled,
|
||||
Interaction::Hovered,
|
||||
Interaction::Pressed,
|
||||
Interaction::Focused,
|
||||
Interaction::Disabled,
|
||||
];
|
||||
|
||||
/// 稳定名字(截图文件名 / 图例 / 测试失败信息用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Interaction::Enabled => "enabled",
|
||||
Interaction::Hovered => "hovered",
|
||||
Interaction::Focused => "focused",
|
||||
Interaction::Pressed => "pressed",
|
||||
Interaction::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否禁用(禁用不是「第六态」,它要不要画容器由组件决定)。
|
||||
pub const fn is_disabled(self) -> bool {
|
||||
matches!(self, Interaction::Disabled)
|
||||
}
|
||||
|
||||
/// 该态要叠的状态层;`None` = 不叠(静息与禁用)。
|
||||
pub const fn layer(self) -> Option<StateLayer> {
|
||||
match self {
|
||||
Interaction::Enabled | Interaction::Disabled => None,
|
||||
Interaction::Hovered => Some(StateLayer::Hover),
|
||||
Interaction::Focused => Some(StateLayer::Focus),
|
||||
Interaction::Pressed => Some(StateLayer::Pressed),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 iced 的 [`button::Status`] 推导(`button` 只有四态,没有 focus)。
|
||||
///
|
||||
/// 用 `button` 做组件的家族一律经这里,**不许自己 match status 再各写一遍颜色**。
|
||||
pub const fn from_status(status: button::Status) -> Self {
|
||||
match status {
|
||||
button::Status::Active => Interaction::Enabled,
|
||||
button::Status::Hovered => Interaction::Hovered,
|
||||
button::Status::Pressed => Interaction::Pressed,
|
||||
button::Status::Disabled => Interaction::Disabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前态叠在 `container` 上的**不透明**颜色;`None` = 该态不叠。
|
||||
///
|
||||
/// `content` 是容器上的前景角色(Filled 按钮用 `OnPrimary`,中性表面上的控件用 `OnSurface`)。
|
||||
pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role) -> Option<Color> {
|
||||
state
|
||||
.layer()
|
||||
.map(|layer| ui.state_over(layer, content, container))
|
||||
}
|
||||
|
||||
/// 禁用态的一对颜色 `(容器, 内容)`,内容按令牌口径叠在**算好的容器**之上。
|
||||
///
|
||||
/// - 容器 = `OnSurface` @ 12%([`DISABLED_CONTAINER_ALPHA`])叠在 `surface` 上;
|
||||
/// - 内容 = 令牌的 `disabled_content(&scheme, 容器)`(`OnSurface` @ 38%)。
|
||||
///
|
||||
/// `ui` 不暴露 [`Scheme`],这里按同一个 flavor 求一次基线方案——调的是
|
||||
/// `unlucky_design` 的**同一个**函数与同一套角色,不是第二份令牌。
|
||||
///
|
||||
/// 顺序必须「先容器后内容」:Compose 也是这么画的,先叠容器再叠 label,
|
||||
/// 所以内容叠的角色是容器实色而不是底下的 surface。
|
||||
pub fn disabled_colors(ui: &Ui, surface: Role) -> (Color, Color) {
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
let base = scheme.get(surface);
|
||||
let container = scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(DISABLED_CONTAINER_ALPHA)
|
||||
.over(base);
|
||||
let content = disabled_content(&scheme, container);
|
||||
|
||||
(to_iced(container), to_iced(content))
|
||||
}
|
||||
|
||||
/// 自定义 `Widget` 的状态机:把 iced 事件折成 [`Interaction`]。
|
||||
///
|
||||
/// 这是 D39 §3 说的「状态机的公共部分」。用法(自定义 Widget 的 `update` 里):
|
||||
///
|
||||
/// ```ignore
|
||||
/// if self.tracker.event(event, cursor.is_over(layout.bounds())) {
|
||||
/// shell.request_redraw(); // 只有真变了才重绘
|
||||
/// }
|
||||
/// let style = style_of(self.tracker.state());
|
||||
/// ```
|
||||
// 本 mission 的按钮家族全部走 iced 的 `button`,没有自绘控件,所以 Tracker 目前只有单测在用;
|
||||
// 它在这里是给四个后续家族(Slider / Switch 轨道这类要自绘的)准备的公共状态机。
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Tracker {
|
||||
enabled: bool,
|
||||
hovered: bool,
|
||||
focused: bool,
|
||||
pressed: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Tracker {
|
||||
/// 新建;`enabled` 由组件自己决定(例如 `on_press.is_some()`)。
|
||||
pub fn new(enabled: bool) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 启用/禁用热切换(禁用时清掉悬停与按下:禁用元素不该留着 hover 高亮)。
|
||||
pub fn set_enabled(&mut self, enabled: bool) -> bool {
|
||||
let changed = self.enabled != enabled;
|
||||
self.enabled = enabled;
|
||||
if !enabled {
|
||||
self.hovered = false;
|
||||
self.pressed = false;
|
||||
self.focused = false;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 键盘焦点(由 `operation`/`shell` 侧的聚焦消息驱动)。
|
||||
pub fn set_focused(&mut self, focused: bool) -> bool {
|
||||
let next = focused && self.enabled;
|
||||
let changed = self.focused != next;
|
||||
self.focused = next;
|
||||
changed
|
||||
}
|
||||
|
||||
/// 折进一个事件;返回「视觉态变了,要重绘吗」。
|
||||
///
|
||||
/// `over` = 指针是否在控件 bounds 内(`Cursor::is_over(bounds)` 的结果,由调用方算——
|
||||
/// 这样本函数不依赖 `Layout`,可以在单测里直接喂)。
|
||||
pub fn event(&mut self, event: &Event, over: bool) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
let before = self.state();
|
||||
match event {
|
||||
Event::Mouse(iced::mouse::Event::CursorMoved { .. }) => self.hovered = over,
|
||||
Event::Mouse(iced::mouse::Event::CursorLeft) => {
|
||||
self.hovered = false;
|
||||
self.pressed = false;
|
||||
}
|
||||
Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) => {
|
||||
self.pressed = over;
|
||||
}
|
||||
Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)) => {
|
||||
self.pressed = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.state() != before
|
||||
}
|
||||
|
||||
/// 当前态(优先级:按下 > 焦点 > 悬停 > 静息;禁用最高)。
|
||||
pub const fn state(&self) -> Interaction {
|
||||
if !self.enabled {
|
||||
Interaction::Disabled
|
||||
} else if self.pressed {
|
||||
Interaction::Pressed
|
||||
} else if self.focused {
|
||||
Interaction::Focused
|
||||
} else if self.hovered {
|
||||
Interaction::Hovered
|
||||
} else {
|
||||
Interaction::Enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_design::color::state::DISABLED_CONTENT_ALPHA;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
/// 契约 §3 的 α 全表:**钉住令牌值**(改令牌会让这条红,改组件抓不到)。
|
||||
#[test]
|
||||
fn state_layer_alphas_are_the_contract_values() {
|
||||
assert!((StateLayer::Hover.alpha() - 0.08).abs() < f32::EPSILON);
|
||||
assert!((StateLayer::Focus.alpha() - 0.10).abs() < f32::EPSILON);
|
||||
assert!((StateLayer::Pressed.alpha() - 0.10).abs() < f32::EPSILON);
|
||||
assert!((StateLayer::Dragged.alpha() - 0.16).abs() < f32::EPSILON);
|
||||
assert!((DISABLED_CONTAINER_ALPHA - 0.12).abs() < f32::EPSILON);
|
||||
assert!((DISABLED_CONTENT_ALPHA - 0.38).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_states_map_onto_four_layers() {
|
||||
assert_eq!(Interaction::ALL.len(), 5);
|
||||
assert_eq!(Interaction::Enabled.layer(), None);
|
||||
assert_eq!(Interaction::Disabled.layer(), None);
|
||||
assert_eq!(Interaction::Hovered.layer(), Some(StateLayer::Hover));
|
||||
assert_eq!(Interaction::Focused.layer(), Some(StateLayer::Focus));
|
||||
assert_eq!(Interaction::Pressed.layer(), Some(StateLayer::Pressed));
|
||||
// focus 与 pressed 同 α(M3 口径)——图上一样是**对的**
|
||||
assert_eq!(
|
||||
Interaction::Focused.layer().map(StateLayer::alpha),
|
||||
Interaction::Pressed.layer().map(StateLayer::alpha)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_color_is_the_token_blend_not_local_math() {
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let ui = ui(flavor);
|
||||
for (state, layer) in [
|
||||
(Interaction::Hovered, StateLayer::Hover),
|
||||
(Interaction::Focused, StateLayer::Focus),
|
||||
(Interaction::Pressed, StateLayer::Pressed),
|
||||
] {
|
||||
assert_eq!(
|
||||
overlay_color(&ui, state, Role::OnPrimary, Role::Primary),
|
||||
Some(ui.state_over(layer, Role::OnPrimary, Role::Primary)),
|
||||
"{flavor:?}/{state:?} 必须逐位等于令牌求值结果"
|
||||
);
|
||||
assert!(
|
||||
overlay_color(&ui, state, Role::OnSurface, Role::Surface).is_some(),
|
||||
"可交互态必须给颜色"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
overlay_color(&ui, Interaction::Enabled, Role::OnSurface, Role::Surface),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
overlay_color(&ui, Interaction::Disabled, Role::OnSurface, Role::Surface),
|
||||
None,
|
||||
"禁用态不叠状态层(它换的是容器/内容色)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_is_weaker_than_pressed_on_the_same_container() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let hover = overlay_color(&ui, Interaction::Hovered, Role::OnSurface, Role::Surface)
|
||||
.expect("hover 有颜色");
|
||||
let pressed = overlay_color(&ui, Interaction::Pressed, Role::OnSurface, Role::Surface)
|
||||
.expect("pressed 有颜色");
|
||||
assert_ne!(hover, pressed, "8% 与 10% 必须能区分");
|
||||
assert_eq!(hover.a, 1.0, "结果必须不透明(GPU/CPU 逐像素一致)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_colors_come_from_the_token_functions() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let scheme = Scheme::baseline(SchemeFlavor::Light);
|
||||
|
||||
let (container, content) = disabled_colors(&ui, Role::Surface);
|
||||
let base = scheme.get(Role::Surface);
|
||||
let expected_container = scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(DISABLED_CONTAINER_ALPHA)
|
||||
.over(base);
|
||||
assert_eq!(
|
||||
container,
|
||||
to_iced(expected_container),
|
||||
"容器 = on-surface@12% 叠表面"
|
||||
);
|
||||
assert_eq!(
|
||||
content,
|
||||
to_iced(disabled_content(&scheme, expected_container))
|
||||
);
|
||||
assert_ne!(container, ui.color(Role::Surface), "12% 叠上去必须能看出来");
|
||||
assert_ne!(content, ui.color(Role::OnSurface), "内容要降到 38%");
|
||||
assert_eq!(container.a, 1.0, "禁用容器也是实色(CPU/GPU 一致)");
|
||||
assert_eq!(content.a, 1.0, "禁用内容也是实色");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_folds_events_into_the_five_states() {
|
||||
let mut tracker = Tracker::new(true);
|
||||
assert_eq!(tracker.state(), Interaction::Enabled);
|
||||
|
||||
let moved = Event::Mouse(iced::mouse::Event::CursorMoved {
|
||||
position: iced::Point::ORIGIN,
|
||||
});
|
||||
assert!(tracker.event(&moved, true), "首次悬停要报变化");
|
||||
assert_eq!(tracker.state(), Interaction::Hovered);
|
||||
assert!(!tracker.event(&moved, true), "重复悬停不该再报变化");
|
||||
|
||||
let press = Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left));
|
||||
assert!(tracker.event(&press, true));
|
||||
assert_eq!(tracker.state(), Interaction::Pressed);
|
||||
|
||||
let release = Event::Mouse(iced::mouse::Event::ButtonReleased(
|
||||
iced::mouse::Button::Left,
|
||||
));
|
||||
assert!(tracker.event(&release, true));
|
||||
assert_eq!(tracker.state(), Interaction::Hovered);
|
||||
|
||||
assert!(tracker.set_focused(true));
|
||||
assert_eq!(tracker.state(), Interaction::Focused, "焦点态优先于悬停");
|
||||
|
||||
let left = Event::Mouse(iced::mouse::Event::CursorLeft);
|
||||
tracker.event(&left, false);
|
||||
assert_eq!(tracker.state(), Interaction::Focused, "鼠标离开不影响焦点");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_tracker_swallows_everything() {
|
||||
let mut tracker = Tracker::new(true);
|
||||
assert!(tracker.set_enabled(false));
|
||||
assert_eq!(tracker.state(), Interaction::Disabled);
|
||||
|
||||
let press = Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left));
|
||||
assert!(!tracker.event(&press, true), "禁用态不响应事件");
|
||||
assert_eq!(tracker.state(), Interaction::Disabled);
|
||||
assert!(!tracker.set_focused(true), "禁用态拿不到焦点");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 布局陷阱的两个固定解法(D39 §4):**要精确尺寸就调这里,别手写**。
|
||||
//!
|
||||
//! ## 陷阱 Q8:`center_x(Length::Fill)` 会把已设的 `.width()` 覆盖成 Fill
|
||||
//!
|
||||
//! iced 的 `container::center_x/center_y/center` 不只是「对齐」——它们**同时**把容器该轴的
|
||||
//! `Length` 改成 `Fill`(源码里 `self.width = Length::Fill`)。于是
|
||||
//! `.width(56).center_x(Length::Fill)` 最终是 `Fill`,56 被父级宽度吃掉:
|
||||
//! M68 的导航药丸因此从 **56×32 变成 88×32**(父级是 88 宽的轨),图上看着「横贯全轨」,
|
||||
//! 而单元测试测的 `indicator_size()` 返回值一直是 56×32 —— **函数对、图错**。
|
||||
//!
|
||||
//! 正确写法是二选一:
|
||||
//!
|
||||
//! - 尺寸留在**自己**身上,居中交给**父级**(`row/column` 的 `align_x`)——`shell/nav.rs` 的做法;
|
||||
//! - 或者用 [`centered_fixed`]:容器写死宽高,**用 `align_x/align_y` 居中**(它们只改对齐,
|
||||
//! 不改 `Length`)。
|
||||
//!
|
||||
//! ## 陷阱 Q9:`Button` 给内容 Fill 约束,`svg().width(24)` 会被忽略
|
||||
//!
|
||||
//! `button` 在 layout 里把「内容可用区」作为约束交给子元素,`svg` 拿到的是 Fill,
|
||||
//! usvg 于是按 `Contain` 撑满内区:**字形渲染成 46px、线宽从 1.5 变 2.9**(M68 视觉复审 P2-2),
|
||||
//! 比导航图标重一倍。反过来「把 `.width(24)` 设在 button 上」也不行——那会缩掉点击区。
|
||||
//!
|
||||
//! 正确写法:**在中间插一层固定尺寸容器**——[`fixed`]:
|
||||
//!
|
||||
//! ```text
|
||||
//! let icon = layout::fixed(24.0, 24.0, icons::view(kind, color)); // 字形 24×24
|
||||
//! button(icon).width(46).height(48) // 命中区仍是 46×48
|
||||
//! ```
|
||||
//!
|
||||
//! ## 为什么还要 [`inset`]
|
||||
//!
|
||||
//! 「左右缩进 16 的一条线」「内容四周留 24」这类**只改内边距、不改尺寸**的语义,
|
||||
//! 写 `.padding(Padding::from([...]))` 时数组顺序(上右下左)极易写反;封装一次,
|
||||
//! 全组件线只写一次顺序。
|
||||
//!
|
||||
//! ## 自检
|
||||
//!
|
||||
//! `components::gate` 会扫 `src/components/**`:出现 `center_x(` / `center_y(` / `.center(`
|
||||
//! 即判红(这条门禁就是 Q8 的回归测试);图标一律经 [`fixed`] / [`centered_fixed`] 包一层。
|
||||
|
||||
use iced::widget::container;
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
/// 固定尺寸的盒子:宽高**只由参数决定**,子元素贴左上(对齐交给子元素自己或父级)。
|
||||
///
|
||||
/// Q9 的解法:`svg` 放进 24×24 的盒子,它拿到的约束就是 24,`Contain` 无从撑满。
|
||||
pub fn fixed<'a, M: 'a>(
|
||||
width: f32,
|
||||
height: f32,
|
||||
content: impl Into<Element<'a, M>>,
|
||||
) -> Element<'a, M> {
|
||||
container(content)
|
||||
.width(Length::Fixed(width))
|
||||
.height(Length::Fixed(height))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 固定尺寸 + 内容居中(Q8 的解法)。
|
||||
///
|
||||
/// 用 `align_x/align_y` 而不是 `center_x/center_y`:前者只改对齐,后者会把本容器的
|
||||
/// `Length` 改成 `Fill`,把参数里的宽高**静默丢掉**。
|
||||
///
|
||||
/// 典型用法:导航轨的选中指示器 [`centered_fixed(56.0, 32.0, icon)`](centered_fixed)。
|
||||
pub fn centered_fixed<'a, M: 'a>(
|
||||
width: f32,
|
||||
height: f32,
|
||||
content: impl Into<Element<'a, M>>,
|
||||
) -> Element<'a, M> {
|
||||
container(content)
|
||||
.width(Length::Fixed(width))
|
||||
.height(Length::Fixed(height))
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 只加内边距、不碰尺寸(`horizontal` = 左右,`vertical` = 上下)。
|
||||
///
|
||||
/// 语义等价于 `.padding(Padding { top: vertical, right: horizontal, bottom: vertical,
|
||||
/// left: horizontal })`,但只暴露两个参数——避免把「上右下左」写反。
|
||||
pub fn inset<'a, M: 'a>(
|
||||
horizontal: f32,
|
||||
vertical: f32,
|
||||
content: impl Into<Element<'a, M>>,
|
||||
) -> Element<'a, M> {
|
||||
container(content)
|
||||
.padding(Padding {
|
||||
top: vertical,
|
||||
right: horizontal,
|
||||
bottom: vertical,
|
||||
left: horizontal,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 单侧缩进(只缩左侧;分隔线的「列表内左缩进 16」用这个)。
|
||||
pub fn inset_start<'a, M: 'a>(start: f32, content: impl Into<Element<'a, M>>) -> Element<'a, M> {
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(Padding {
|
||||
left: start,
|
||||
..Padding::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Q8 的**形态级**钉法:这三个 API 一旦内部改用 `center_*`,这条测试之外的门禁
|
||||
/// (`components::gate`)也会红;这里再钉一次「参数就是参数」,防止签名被悄悄改成
|
||||
/// `impl Into<Length>` 之类。
|
||||
#[test]
|
||||
fn helpers_take_pixel_sizes_not_lengths() {
|
||||
let _: fn(f32, f32, Element<'static, ()>) -> Element<'static, ()> = fixed;
|
||||
let _: fn(f32, f32, Element<'static, ()>) -> Element<'static, ()> = centered_fixed;
|
||||
let _: fn(f32, f32, Element<'static, ()>) -> Element<'static, ()> = inset;
|
||||
let _: fn(f32, Element<'static, ()>) -> Element<'static, ()> = inset_start;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_build_a_tree_without_panicking() {
|
||||
let _: Element<'_, ()> = fixed(24.0, 24.0, iced::widget::text("x"));
|
||||
let _: Element<'_, ()> = centered_fixed(56.0, 32.0, iced::widget::text("x"));
|
||||
let _: Element<'_, ()> = inset(16.0, 8.0, iced::widget::text("x"));
|
||||
let _: Element<'_, ()> = inset_start(16.0, iced::widget::text("x"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! 组件库(D39 定案):五家族并行,**目录所有权切分**,接口见
|
||||
//! `DevDocs/SubAgentsTalk/app-iced-组件库契约.md`。
|
||||
//!
|
||||
//! ## 模块地图与归属(谁能写哪里)
|
||||
//!
|
||||
//! ```text
|
||||
//! components/
|
||||
//! mod.rs ← 本文件(COMPBASE 独占):只声明子模块,不含实现
|
||||
//! interaction.rs ← COMPBASE 独占:hover/focus/pressed/disabled 的唯一实现
|
||||
//! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法
|
||||
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
|
||||
//! divider.rs ← COMPBASE(15 §22)
|
||||
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
|
||||
//! inputs/ ← 同上(§9/§20/§11/§8)
|
||||
//! feedback/ ← 同上(§12/§14/§7/§15)
|
||||
//! navigation/ ← 同上(§4/§6/§21/§16)
|
||||
//! gallery/ ← 视觉验收 harness(`--screen=gallery`)
|
||||
//! ```
|
||||
//!
|
||||
//! **后续 mission 只写自己目录内的文件,不改本文件**:四个家族桩已经在这里声明好,
|
||||
//! 每个家族文件自带一个 `demo()`(占位 → 真组件),gallery 侧对应文件也在
|
||||
//! `gallery/` 里预置了同构的桩。加组件 = 只改自家目录里的文件。
|
||||
//!
|
||||
//! ## 组件签名纪律(契约 §2)
|
||||
//!
|
||||
//! 组件**对消息类型泛型**,不 import `crate::app::Message`:
|
||||
//!
|
||||
//! ```text
|
||||
//! pub fn filled<'a, M: Clone + 'a>(ui: &'a Ui, ...) -> Element<'a, M>
|
||||
//! ```
|
||||
//!
|
||||
//! `Ui` 一律 `&'a Ui` 传入,组件内部**不许** `Ui::new`;深浅热切靠外部重建整树。
|
||||
//!
|
||||
//! ## 令牌纪律
|
||||
//!
|
||||
//! 组件里**零**色值 / 字号 / 半径 / 间距 / α 字面量:颜色走 [`crate::theme::Ui`],
|
||||
//! 状态层走 [`interaction`],尺寸走 `unlucky_design` 的 `LayoutMetrics` / `Space` /
|
||||
//! `IconSize` / `ShapeScale`。缺令牌 → 登记 G 编号(续 15 §25 的 G13),**不发明**。
|
||||
//! [`gate`] 里的测试会扫本目录源码执行这条纪律。
|
||||
//!
|
||||
//! ## 为什么 `components` 由 `app.rs` 声明
|
||||
//!
|
||||
//! `src/lib.rs` 是五条组件线共用的文件(并行线改它必然冲突)。本模块挂在
|
||||
//! `app.rs` 的 `#[path]` 声明下,路径仍是 `src/components/`,这样 COMPBASE 的
|
||||
//! 全部改动都落在自己 scope 内。
|
||||
|
||||
pub mod buttons;
|
||||
pub mod divider;
|
||||
pub mod feedback;
|
||||
pub mod gallery;
|
||||
pub mod inputs;
|
||||
pub mod interaction;
|
||||
pub mod layout;
|
||||
pub mod navigation;
|
||||
pub mod selection;
|
||||
|
||||
#[cfg(test)]
|
||||
mod gate;
|
||||
@@ -0,0 +1,35 @@
|
||||
//! **导航类家族(桩)**:由后续 mission 填充(§4 ListItem / §6 Tabs / §21 NavigationDrawer / §16 AppBar)。
|
||||
//!
|
||||
//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**:
|
||||
//! 家族模块已经在 `mod.rs` 里声明好,`gallery/navigation.rs` 也已经在 `gallery/mod.rs` 里声明好,
|
||||
//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。
|
||||
//!
|
||||
//! 填充时请照抄按钮家族的分层:
|
||||
//!
|
||||
//! - 几何进 `navigation/geometry.rs`(数值从 `ComponentMetrics::NAVIGATION_DRAWER` /
|
||||
//! `LayoutMetrics` / `Space` / `ShapeScale` 取,缺令牌登记 G 编号);
|
||||
//! - 状态层只能走 [`crate::app::components::interaction`];
|
||||
//! - 精确尺寸用 [`crate::app::components::layout`]——导航选中指示器 **56×32** 就是 Q8 的历史事故现场,
|
||||
//! 必须用 `layout::centered_fixed`(`center_x(Length::Fill)` 会把它撑成 88 宽)。
|
||||
|
||||
use iced::widget::{container, text};
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 占位 demo(ASCII 文案,理由同 `selection::demo`)。
|
||||
pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text("navigation · list item / tabs / drawer / app bar — pending")
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.padding(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! **选择类家族(桩)**:由后续 mission 填充(§18 Checkbox / §19 Radio / §10 Switch / §13 Chip)。
|
||||
//!
|
||||
//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**:
|
||||
//! 家族模块已经在 `mod.rs` 里声明好,`gallery/selection.rs` 也已经在 `gallery/mod.rs` 里声明好,
|
||||
//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。
|
||||
//!
|
||||
//! 填充时请照抄按钮家族的分层:
|
||||
//!
|
||||
//! - 几何进 `selection/geometry.rs`(数值一律从 `ComponentMetrics` / `Space` / `ShapeScale` 取,
|
||||
//! 缺令牌登记 G 编号);
|
||||
//! - 状态层只能走 [`crate::app::components::interaction`](α 一处定义);
|
||||
//! - 精确尺寸用 [`crate::app::components::layout`](Q8/Q9 的解法),别手写 `.width(x)`。
|
||||
|
||||
use iced::widget::{container, text};
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 占位 demo:一句 "pending",供 `--screen=gallery` 保持五段结构完整。
|
||||
///
|
||||
/// 后续 mission 把本函数换成真组件矩阵即可(`gallery/selection.rs` 调的就是它)。
|
||||
/// 文案用 ASCII:干净 CI 上可能没有中文字体(`fonts` 只保证拉丁族名),豆腐块会污染验收图。
|
||||
pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text("selection · checkbox / radio / switch / chip — pending")
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.padding(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
Reference in new issue
Block a user