feat(app-iced): Ink Cat / Silver Cat 换肤 —— 双色板默认 + 规格卡组件几何(M229)
产品出厂外观从 tone 派生的 `Scheme::baseline` 切到品牌色板:深色 = Ink(淡绿)、 浅色 = Silver(钢蓝),D38 取色链(背景图 / 壁纸)仍然优先,取色失败回落到色板。 主题三档接进设置页「通用 → 主题」:Ink / Silver / 跟随系统(落盘仍是 `appearance.theme_mode` 的 dark / light / auto,不新增配置键)。 组件几何按规格卡对齐(M228 的 `inkcat` 登记项逐条落地): - 卡片圆角 12 → 16;实例元信息卡改用新的 ELEVATED CARD(无描边、SurfaceContainer 底、内距 24、上标注 11/600) - Snackbar 48/4 → 52/16(双行 72) - Checkbox 20/rx2 → 18/rx4;Radio 20 → 18 Ø(内点仍 10) - 按钮加 XS~XL 五档(产品仍只用 S=40),内距改走 ControlSize::pad_x - 新增 FAB 四态(40/56/80/160×56)与图标按钮第四式 outlined - 语言与主题两行 chips 统一成规格卡 chip(32 高 / rx8) `theme.rs` 原本正好 400 行,先把单测纯搬移到 `theme/tests.rs` 再加色板字段; `app/tests.rs`、`settings/general.rs` 同样按纯搬移拆分守 400 行硬线。 验证:cargo fmt --all --check、cargo check -p unlucky-app-iced --all-targets、 cargo test --workspace(除 clippy 前的一轮 flake,见报告)、cargo clippy --workspace --all-targets -- -D warnings、全仓 400 行扫描。视觉矩阵 42 张在 DevDocs/artifacts/m229/(gitignored,不进提交)。
This commit is contained in:
1 parent
a06b7ff4ec
commit
db541c820e
44 files changed
+1439
-300
No files matched your search
@@ -154,7 +154,10 @@ impl App {
|
||||
avatars: pages::account::avatar::Cache::default(),
|
||||
corner_applied: None,
|
||||
};
|
||||
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
|
||||
// M229:设置页「主题」行的选中态来自设置组自有状态(`language_appearance::State::theme`),
|
||||
// 唯一写者是 `App::set_theme_mode`;这里把启动时读到的档位灌进去,首帧才不会画错档。
|
||||
app.settings.language_appearance.theme = theme_mode;
|
||||
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是色板。
|
||||
app.rebuild_ui();
|
||||
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
|
||||
app.hydrate_ai_settings();
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
|
||||
|
||||
use crate::app::diag::diag;
|
||||
use crate::app::message::Message;
|
||||
use iced::Task;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::prefs::ThemeMode;
|
||||
|
||||
@@ -73,6 +75,8 @@ impl App {
|
||||
}
|
||||
let mode = self.bridge.load().1;
|
||||
self.theme_mode = mode;
|
||||
// M229:设置页那一行画的是这个字段(唯一写者见 [`App::set_theme_mode`])。
|
||||
self.settings.language_appearance.theme = mode;
|
||||
match mode {
|
||||
ThemeMode::Light => self.set_flavor(SchemeFlavor::Light),
|
||||
ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark),
|
||||
@@ -80,6 +84,68 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// 换主题档(M229:设置页「通用 → 主题」→ `pages::Message::Theme`)。
|
||||
///
|
||||
/// 四件事:①改 `theme_mode`(唯一真相)②同步设置页那一行的档位 ③重建 `Ui`(色板换了,
|
||||
/// `Theme::name` 与 49 个角色一起变,iced 才会重算缓存)④落盘 `appearance.theme_mode`。
|
||||
/// `Auto` 自己没有颜色,要等系统回答——所以返回 `iced::system::theme()` 那个 Task,
|
||||
/// 由既有的 `Message::SystemTheme` 分支落地(它只在 `theme_mode == Auto` 时吃这条消息)。
|
||||
pub(in crate::app) fn set_theme_mode(&mut self, mode: ThemeMode) -> Task<Message> {
|
||||
self.settings.language_appearance.theme = mode;
|
||||
if mode == self.theme_mode {
|
||||
return Task::none();
|
||||
}
|
||||
self.theme_mode = mode;
|
||||
|
||||
let prefs = self.appearance_prefs.clone();
|
||||
self.bridge.persist(&prefs, mode);
|
||||
match self.bridge.path() {
|
||||
Some(path) => diag!("[theme] mode={} 已写回 {}", mode.as_str(), path.display()),
|
||||
None => diag!(
|
||||
"[theme] mode={} 未落盘(数据根解析不出来,内存态运行):重启会回到旧值",
|
||||
mode.as_str()
|
||||
),
|
||||
}
|
||||
|
||||
match mode {
|
||||
ThemeMode::Light => {
|
||||
self.set_flavor(SchemeFlavor::Light);
|
||||
self.after_theme_switch();
|
||||
Task::none()
|
||||
}
|
||||
ThemeMode::Dark => {
|
||||
self.set_flavor(SchemeFlavor::Dark);
|
||||
self.after_theme_switch();
|
||||
Task::none()
|
||||
}
|
||||
// 跟随系统:当前先按已有深浅画,紧接着由系统主题回答覆盖
|
||||
// (`set_flavor` 有「同档不重建」的保护,所以这里不必先猜一次)。
|
||||
ThemeMode::Auto => iced::system::theme().map(Message::SystemTheme),
|
||||
}
|
||||
}
|
||||
|
||||
/// 换档之后重建视图模型 + 记一行取证日志(色板名就是证据:`ink` / `silver`)。
|
||||
fn after_theme_switch(&mut self) {
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
diag!(
|
||||
"[theme] mode={} board={} Theme::name={}",
|
||||
self.theme_mode().as_str(),
|
||||
self.board_name(),
|
||||
self.ui.name()
|
||||
);
|
||||
}
|
||||
|
||||
/// 当前主题档(设置页那一行的选中态来自 `settings.groups`,这里是取证与测试的读数口)。
|
||||
pub(in crate::app) fn theme_mode(&self) -> ThemeMode {
|
||||
self.theme_mode
|
||||
}
|
||||
|
||||
/// 当前色板名(日志与测试用:`ink` / `silver`;取色方案接管时仍报它所属深浅的那块)。
|
||||
pub(in crate::app) fn board_name(&self) -> &'static str {
|
||||
self.ui.board().name()
|
||||
}
|
||||
|
||||
/// 当前令牌求值器(测试与截图日志用)。
|
||||
pub(in crate::app) fn ui(&self) -> &Ui {
|
||||
&self.ui
|
||||
|
||||
@@ -54,11 +54,11 @@ fn switching_flavor_rebuilds_styles_but_keeps_ui_state() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
app.state.navigate(Route::Logs);
|
||||
app.refresh_view();
|
||||
assert_eq!(app.ui().name(), "ull-m3-light");
|
||||
assert_eq!(app.ui().name(), "ull-silver");
|
||||
|
||||
app.set_flavor(SchemeFlavor::Dark);
|
||||
|
||||
assert_eq!(app.ui().name(), "ull-m3-dark", "切主题要换主题名");
|
||||
assert_eq!(app.ui().name(), "ull-ink", "切主题要换主题名");
|
||||
assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态");
|
||||
assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由");
|
||||
assert!(app.options.screenshot.is_none(), "切主题不许改启动参数");
|
||||
@@ -78,7 +78,7 @@ fn screenshot_mode_never_waits_for_the_system_theme() {
|
||||
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
|
||||
|
||||
let app = headless(options, SchemeFlavor::Light);
|
||||
assert!(app.has_screenshot() && app.ui().name() == "ull-m3-light");
|
||||
assert!(app.has_screenshot() && app.ui().name() == "ull-silver");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -336,7 +336,7 @@ fn the_page_navigation_entry_also_detaches() {
|
||||
fn screenshot_mode_ignores_the_settings_file_theme() {
|
||||
let mut app = headless(screenshot_options(), SchemeFlavor::Light);
|
||||
app.apply_config_theme();
|
||||
assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行");
|
||||
assert_eq!(app.ui().name(), "ull-silver", "截图模式的主题只认命令行");
|
||||
}
|
||||
|
||||
/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。
|
||||
@@ -355,7 +355,7 @@ fn the_system_theme_never_overrides_an_explicit_one() {
|
||||
let mut auto = headless(Options::default(), SchemeFlavor::Light);
|
||||
assert_eq!(auto.theme_mode, ThemeMode::Auto);
|
||||
let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark));
|
||||
assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统");
|
||||
assert_eq!(auto.ui().name(), "ull-ink", "auto 模式要跟随系统");
|
||||
|
||||
// ② 配置文件显式 dark:系统给浅色也不许改
|
||||
let mut pinned = headless(Options::default(), SchemeFlavor::Dark);
|
||||
@@ -363,7 +363,7 @@ fn the_system_theme_never_overrides_an_explicit_one() {
|
||||
let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light));
|
||||
assert_eq!(
|
||||
pinned.ui().name(),
|
||||
"ull-m3-dark",
|
||||
"ull-ink",
|
||||
"配置文件说 dark,系统说 light:不许改"
|
||||
);
|
||||
|
||||
@@ -378,7 +378,9 @@ fn the_system_theme_never_overrides_an_explicit_one() {
|
||||
let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light));
|
||||
assert_eq!(
|
||||
explicit.ui().name(),
|
||||
"ull-m3-dark",
|
||||
"ull-ink",
|
||||
"--theme 优先级最高,系统主题不许盖"
|
||||
);
|
||||
}
|
||||
|
||||
mod theme;
|
||||
@@ -181,7 +181,7 @@ fn a_decodable_png_makes_the_background_and_the_palette_active() {
|
||||
"取色成功后 Ui 要换成解析出的方案"
|
||||
);
|
||||
// 换 scheme 之后主题名仍是同一个(深浅没变),但 primary 必须与 baseline 不同。
|
||||
assert_eq!(app.ui().name(), "ull-m3-light");
|
||||
assert_eq!(app.ui().name(), "ull-silver");
|
||||
let baseline = crate::theme::Ui::new(SchemeFlavor::Light, FAMILIES);
|
||||
assert_ne!(
|
||||
app.ui().color(unlucky_design::color::Role::Primary),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 主题三档(Ink / Silver / 跟随系统)的落点测试(M229;从 `app/tests.rs` 拆出守 400 行)。
|
||||
//!
|
||||
//! 这一份只管**用户点设置页那三枚 chip 之后**发生的事:换色板、同步设置组状态、落盘。
|
||||
//! `Auto`(跟随系统)那条路在 `app::tests::the_system_theme_never_overrides_an_explicit_one`。
|
||||
|
||||
use crate::app::message::Message;
|
||||
use crate::app::state::App;
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::prefs::ThemeMode;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 一个把配置写在临时文件里的 App:测「落盘」必须真的过文件(内存桥读回来永远是出厂值)。
|
||||
fn with_config() -> (App, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("ull-theme-m229-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
std::fs::write(&path, "{}").expect("写配置文件");
|
||||
|
||||
let app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::with_path(&path),
|
||||
);
|
||||
(app, dir)
|
||||
}
|
||||
|
||||
/// M229:设置页「通用 → 主题」三档(Ink / Silver / 跟随系统)真的换色板、真的落盘。
|
||||
///
|
||||
/// 抓得住:①`pages::Message::Theme` 没接进 `on_page`(点了没反应)②接了但不换 `Ui`
|
||||
/// (只有选中态变了,界面颜色还是旧的)③同步设置组状态漏了(设置页首帧/刷新后画错档)
|
||||
/// ④落盘漏了(重启回旧值——这里读的是内存桥的 `theme_mode`)。
|
||||
/// 抓不住:色板好不好看、像素级差异(那要截图,见 `DevDocs/artifacts/m229/`)。
|
||||
#[test]
|
||||
fn the_theme_message_switches_the_board_and_persists_it() {
|
||||
let (mut app, dir) = with_config();
|
||||
assert_eq!(app.board_name(), "silver", "首帧是浅色主方案(Silver)");
|
||||
|
||||
// 选 Ink(= 深色主方案)
|
||||
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Dark)));
|
||||
assert_eq!(app.theme_mode(), ThemeMode::Dark);
|
||||
assert_eq!(app.board_name(), "ink", "Ink 深板要真切过去");
|
||||
assert_eq!(app.ui().flavor(), SchemeFlavor::Dark);
|
||||
assert_eq!(
|
||||
app.settings.language_appearance.theme,
|
||||
ThemeMode::Dark,
|
||||
"设置页那一行的档位要跟着改"
|
||||
);
|
||||
// 落盘:真的过了文件(重新打开同一个配置读回来就是 dark)
|
||||
assert_eq!(
|
||||
SettingsBridge::with_path(dir.join("config/launcher.json"))
|
||||
.load()
|
||||
.1,
|
||||
ThemeMode::Dark,
|
||||
"主题档要落盘"
|
||||
);
|
||||
|
||||
// 选 Silver(= 浅色主方案)
|
||||
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Light)));
|
||||
assert_eq!(app.board_name(), "silver");
|
||||
assert_eq!(app.ui().flavor(), SchemeFlavor::Light);
|
||||
assert_eq!(
|
||||
SettingsBridge::with_path(dir.join("config/launcher.json"))
|
||||
.load()
|
||||
.1,
|
||||
ThemeMode::Light
|
||||
);
|
||||
|
||||
// 同一档再点一次:不许白重建
|
||||
let refreshes = app.refreshes;
|
||||
let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Light)));
|
||||
assert_eq!(app.refreshes, refreshes, "同一档不该再重建视图模型");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -66,6 +66,9 @@ impl App {
|
||||
// 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」,
|
||||
// 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。
|
||||
pages::Message::Language(choice) => self.set_language(choice),
|
||||
// M229:换主题档(Ink / Silver / 跟随系统)——换 `Ui` 的色板 + 落盘
|
||||
// `appearance.theme_mode`。与语言行同一条「页面消息直接改本地 + 落盘」的路。
|
||||
pages::Message::Theme(mode) => return self.set_theme_mode(mode),
|
||||
// M109:内容库那一套意图在自己的文件里穷尽分发(`update/content.rs`)
|
||||
pages::Message::Content(message) => return self.on_content(message),
|
||||
// M95:摘要落剪贴板(文本由页面造,这里只搬)
|
||||
|
||||
@@ -33,7 +33,7 @@ use update::update_of;
|
||||
/// 上一次记过的「设置页看到的组 + 主题」(`None` = 还没记过)。
|
||||
///
|
||||
/// 只给 [`note_screenshot_group`] 用:`view` 每帧都会跑,日志必须只在身份**变化**时落一行。
|
||||
/// 用 `Mutex` 而不是 `AtomicUsize` 位打包:主题是字符串(`ull-m3-light` / `ull-m3-dark`),
|
||||
/// 用 `Mutex` 而不是 `AtomicUsize` 位打包:主题是字符串(`ull-ink` / `ull-silver`),
|
||||
/// 把两件事挤进一个整数要引入「高半位 = 深色」这种与主题命名耦合的约定,不如老实用一对值。
|
||||
static SHOT_GROUP_LOGGED: std::sync::Mutex<Option<(usize, String)>> = std::sync::Mutex::new(None);
|
||||
|
||||
@@ -43,7 +43,7 @@ static SHOT_GROUP_LOGGED: std::sync::Mutex<Option<(usize, String)>> = std::sync:
|
||||
///
|
||||
/// 举证图自带不了身份。M201 的事故就是 worker 交了一张「外观」页当下载组的证据,报告里的结论
|
||||
/// 建立在一个错的「这是哪个组」上,直到视觉 reviewer 自己重拍才发现。所以每张交付图都要能对上
|
||||
/// 一行日志:`[shot] settings route=Settings group=2 theme=ull-m3-light`。
|
||||
/// 一行日志:`[shot] settings route=Settings group=2 theme=ull-silver`。
|
||||
///
|
||||
/// ## 为什么只在变化时打
|
||||
///
|
||||
|
||||
@@ -10,7 +10,6 @@ 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;
|
||||
@@ -48,7 +47,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
|
||||
variant: Variant,
|
||||
spec: super::Button<'a, M>,
|
||||
) -> Element<'a, M> {
|
||||
let geometry = Geometry::of(ui, variant, spec.leading.is_some());
|
||||
let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size);
|
||||
let (fill, content, border) = variant.roles();
|
||||
let forced = spec.state.or(if spec.on_press.is_none() {
|
||||
Some(Interaction::Disabled)
|
||||
@@ -70,12 +69,13 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
|
||||
// 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。
|
||||
let ink = content_color(ui, forced, content, fill, variant.disabled_content());
|
||||
|
||||
// 标签排版跟尺寸档走:`ControlSize::label()` 已把「字号跟字级、字重固定 500」定死
|
||||
// (XS/S = LabelLarge 14、M = BodyLarge 16、L = HeadlineSmall 24、XL = HeadlineLarge 32)。
|
||||
let label_style = geometry.size.label();
|
||||
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)))
|
||||
.size(label_style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(label_style.line_height_px)))
|
||||
.font(ui.font_of(label_style))
|
||||
.color(ink);
|
||||
|
||||
let mut row = Row::new().align_y(Alignment::Center);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! FAB 四态(规格卡 `FAB / SMALL - DEFAULT - MEDIUM - EXTENDED`)。
|
||||
//!
|
||||
//! ## 规格卡原值(`[spec]` = 图上的坐标/字号,几何全部来自 [`FabSize`] 的令牌访问器)
|
||||
//!
|
||||
//! | 档 | 尺寸 | 圆角 | 图标 | 标签 | 规格卡依据 |
|
||||
//! |---|---|---|---|---|---|
|
||||
//! | Small | 40×40 | 12(`Medium`) | 20 | — | `+` 徽章式小 FAB |
|
||||
//! | Default | 56×56 | 16(`Large`) | 26 | — | M3 默认 FAB |
|
||||
//! | Medium | 80×80 | 20(`LargeIncreased`) | 34 | — | 中大档 |
|
||||
//! | Extended | 160×56(最小宽) | 16(`Large`) | 24 | 15 | `+ New`:起始内距 28、图标↔标签 8 |
|
||||
//!
|
||||
//! ## 配色:`PrimaryContainer` / `OnPrimaryContainer`
|
||||
//!
|
||||
//! 规格卡上四颗 FAB 都是**淡绿底 + 深绿字形**,M3 的对应角色正是 `primary-container` /
|
||||
//! `on-primary-container`(`boards/ink.rs` 的 `[spec]` 行注释也这么标:`FAB 底` / `FAB 图标`)。
|
||||
//! 所以这里不引入新角色,也不画阴影——规格卡上是纯色块,M3 的 FAB 高程由容器色表达。
|
||||
//!
|
||||
//! 色值不写在这份文档里:`tests/no_hardcoded_tokens.rs` 会扫 `src/**` 的十六进制色值,
|
||||
//! 要核对数字请看 `boards/ink.rs` 的 `ANCHORS` 表(那是唯一真源)。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! 按钮家族(五式 × 五态 × 五档)已经在 `buttons/mod.rs` + `assemble.rs` 里贴着 400 行;FAB 的
|
||||
//! 几何与按钮**不共享**(圆角不是半高、图标随档放大、扩展态有自己的内距),硬塞进去只会让两边
|
||||
//! 的读取路径都变长。共享的只有 `interaction` 的状态层与图标烘焙工具。
|
||||
|
||||
use iced::widget::{button, container, row, text};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::inkcat::size::FabSize;
|
||||
use unlucky_design::typography::{TypeRole, TypeStyle, Weight};
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::super::layout;
|
||||
|
||||
/// 一颗 FAB:图标必有,标签只有扩展态有;`on_press = None` 即禁用。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Fab<'a, M> {
|
||||
icon: IconKind,
|
||||
label: Option<&'a str>,
|
||||
size: FabSize,
|
||||
on_press: Option<M>,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> Fab<'a, M> {
|
||||
/// 标准 FAB(默认档 = 56)。
|
||||
pub fn new(icon: IconKind, on_press: M) -> Self {
|
||||
Self {
|
||||
icon,
|
||||
label: None,
|
||||
size: FabSize::Default,
|
||||
on_press: Some(on_press),
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 扩展态(药丸 + 图标 + 标签)。`label` 文案由调用方给(组件不碰 i18n)。
|
||||
pub fn extended(icon: IconKind, label: &'a str, on_press: M) -> Self {
|
||||
Self {
|
||||
label: Some(label),
|
||||
size: FabSize::Extended,
|
||||
..Self::new(icon, on_press)
|
||||
}
|
||||
}
|
||||
|
||||
/// 换档(Small / Default / Medium / Extended)。
|
||||
pub fn size(mut self, size: FabSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// 强制视觉态(gallery 取证用;运行期不要调)。
|
||||
pub fn state(mut self, state: Interaction) -> Self {
|
||||
self.state = Some(state);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 画一颗 FAB。
|
||||
pub fn fab<'a, M: Clone + 'a>(ui: &'a Ui, spec: Fab<'a, M>) -> Element<'a, M> {
|
||||
let size = spec.size;
|
||||
let forced = spec.state.or(if spec.on_press.is_none() {
|
||||
Some(Interaction::Disabled)
|
||||
} else {
|
||||
None
|
||||
});
|
||||
let ink = match forced {
|
||||
// 禁用:38% 的内容色叠在容器上(与按钮族的容器档同一口径)
|
||||
Some(Interaction::Disabled) => {
|
||||
interaction::disabled_button_content(ui, Role::OnPrimaryContainer, true)
|
||||
}
|
||||
_ => ui.color(Role::OnPrimaryContainer),
|
||||
};
|
||||
|
||||
let glyph = layout::fixed(
|
||||
size.icon_px(),
|
||||
size.icon_px(),
|
||||
icons::view_at::<M>(spec.icon, ink, size.icon_px()),
|
||||
);
|
||||
|
||||
let content: Element<'a, M> = match spec.label {
|
||||
// 扩展态:图标 + 标签(起始内距 28、间距 8,规格卡实测值)
|
||||
Some(label) => {
|
||||
let label_style = TypeStyle {
|
||||
weight: Weight::Medium,
|
||||
..TypeRole::BodyMedium.style()
|
||||
};
|
||||
let label_px = size.label_px();
|
||||
row![
|
||||
glyph,
|
||||
text(label)
|
||||
.size(label_px)
|
||||
.font(ui.font_of(TypeStyle {
|
||||
size_px: label_px,
|
||||
..label_style
|
||||
}))
|
||||
.color(ink),
|
||||
]
|
||||
.spacing(size.gap_px())
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding {
|
||||
top: 0.0,
|
||||
right: 28.0,
|
||||
bottom: 0.0,
|
||||
left: size.pad_start_px(),
|
||||
})
|
||||
.into()
|
||||
}
|
||||
None => container(glyph)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.into(),
|
||||
};
|
||||
|
||||
let shape = size.shape();
|
||||
let radius = size.radius_px();
|
||||
let style = move |_theme: &iced::Theme, status: button::Status| {
|
||||
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
|
||||
let background = match state.layer() {
|
||||
// 状态层叠在容器色上(与按钮族同一条 `overlay_color` 口径)
|
||||
Some(_) => interaction::overlay_color(
|
||||
ui,
|
||||
state,
|
||||
Role::OnPrimaryContainer,
|
||||
Role::PrimaryContainer,
|
||||
)
|
||||
.or(Some(ui.color(Role::PrimaryContainer))),
|
||||
None => Some(ui.color(Role::PrimaryContainer)),
|
||||
};
|
||||
let text_color = if state.is_disabled() {
|
||||
interaction::disabled_button_content(ui, Role::OnPrimaryContainer, true)
|
||||
} else {
|
||||
ui.color(Role::OnPrimaryContainer)
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background: background.map(Background::Color),
|
||||
text_color,
|
||||
border: Border {
|
||||
radius: iced::border::Radius::new(radius),
|
||||
..Border::default()
|
||||
},
|
||||
// 规格卡不画阴影;M3 的 FAB 高程靠容器色(rubric §7 的「L0/L1 不用阴影」同源)
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
};
|
||||
|
||||
let width = if matches!(size, FabSize::Extended) {
|
||||
// 扩展态是最小宽(160):标签更长就撑开,短了就顶到最小宽
|
||||
Length::Fixed(size.width_px())
|
||||
} else {
|
||||
Length::Fixed(size.width_px())
|
||||
};
|
||||
|
||||
let element = button(content)
|
||||
.width(width)
|
||||
.height(Length::Fixed(size.height_px()))
|
||||
.padding(Padding::default())
|
||||
.style(style);
|
||||
|
||||
debug_assert!(radius > 0.0, "FAB 圆角必须来自 ShapeScale({shape:?})");
|
||||
debug_assert!(
|
||||
matches!(size, FabSize::Extended) || size.pad_start_px() == 0.0,
|
||||
"只有扩展态有起始内距"
|
||||
);
|
||||
|
||||
match spec.on_press {
|
||||
Some(message) => element.on_press(message).into(),
|
||||
None => element.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,40 @@
|
||||
//! FAB 的几何断言(四档尺寸/圆角/图标/内距,全部对着规格卡原值)。
|
||||
//!
|
||||
//! 抓得住:档位与规格卡错位(例如把 Default 写成 48)、圆角被写成半高、扩展态内距悄悄改成 M3 的 16。
|
||||
//! 抓不住:配色与状态层(那走 `interaction` 的既有测试)、以及图上好不好看(D30)。
|
||||
|
||||
use unlucky_design::inkcat::size::FabSize;
|
||||
|
||||
#[test]
|
||||
fn the_four_states_are_the_spec_sizes() {
|
||||
for (size, width, height, radius) in [
|
||||
(FabSize::Small, 40.0, 40.0, 12.0),
|
||||
(FabSize::Default, 56.0, 56.0, 16.0),
|
||||
(FabSize::Medium, 80.0, 80.0, 20.0),
|
||||
(FabSize::Extended, 160.0, 56.0, 16.0),
|
||||
] {
|
||||
assert_eq!(size.width_px(), width, "{size:?} 宽");
|
||||
assert_eq!(size.height_px(), height, "{size:?} 高");
|
||||
assert_eq!(size.radius_px(), radius, "{size:?} 圆角");
|
||||
}
|
||||
assert_eq!(FabSize::ALL.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_extended_state_carries_a_label_and_its_inset() {
|
||||
for size in [FabSize::Small, FabSize::Default, FabSize::Medium] {
|
||||
assert_eq!(size.label_px(), 0.0, "{size:?} 不该有标签");
|
||||
assert_eq!(size.pad_start_px(), 0.0);
|
||||
}
|
||||
assert_eq!(FabSize::Extended.label_px(), 15.0);
|
||||
assert_eq!(FabSize::Extended.pad_start_px(), 28.0);
|
||||
assert_eq!(FabSize::Extended.gap_px(), 8.0);
|
||||
}
|
||||
|
||||
/// 图标随档放大(20 / 26 / 34),扩展态回到 24(它与标签同行,跟着文字尺度走)。
|
||||
#[test]
|
||||
fn the_glyph_grows_with_the_smaller_states() {
|
||||
assert!(FabSize::Small.icon_px() < FabSize::Default.icon_px());
|
||||
assert!(FabSize::Default.icon_px() < FabSize::Medium.icon_px());
|
||||
assert_eq!(FabSize::Extended.icon_px(), 24.0);
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
use iced::Padding;
|
||||
|
||||
use unlucky_design::icons::IconSize;
|
||||
use unlucky_design::inkcat::size::ControlSize;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::theme::{Ui, STROKE_PX};
|
||||
@@ -44,29 +45,50 @@ pub struct Geometry {
|
||||
pub gap: f32,
|
||||
/// 描边宽度;`0` = 该式无描边。
|
||||
pub stroke: f32,
|
||||
/// 尺寸档(决定高、圆角折算边、内距与标签字级)。
|
||||
pub size: ControlSize,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
/// 按变体 + 有没有前置图标求值(带图标时左内边距收到 16,右仍 24)。
|
||||
/// 按变体 + 有没有前置图标求值(默认 **S 档 = 40 高**,产品页用的就是这一档)。
|
||||
///
|
||||
/// `#[cfg(test)]`:产品路径一律走 [`Geometry::sized`](`assemble` 里带上了尺寸档),
|
||||
/// 这里只给单测一个「S 档」的便捷入口,留成产品 API 只会让 `dead_code` 响。
|
||||
#[cfg(test)]
|
||||
pub fn of(ui: &Ui, variant: Variant, leading_icon: bool) -> Self {
|
||||
Self::sized(ui, variant, leading_icon, ControlSize::S)
|
||||
}
|
||||
|
||||
/// 按尺寸档求值(规格卡 `BUTTONS / XS - XL`)。
|
||||
///
|
||||
/// 内距取 [`ControlSize::pad_x`](规格卡没写内距,那是 `[估]` 的「半高向下取 4 栅格、封顶 48」
|
||||
/// 推导值);带前置图标时左侧再收 **4**(`Space::X1`)——药丸左半圆里图标要离弧远一点,
|
||||
/// 与 M3 的「带图标左内距更小」是同一条观感规则。S 档因此是 **20 / 16 / 20**,
|
||||
/// 而 M3 官方是 24 / 16 / 24:差 4,登记在 M229 报告里(规格卡没给内距,我们按它自己的
|
||||
/// 半高规则推,而不是回到官方值——否则五档里只有 S 一种内距口径)。
|
||||
pub fn sized(ui: &Ui, variant: Variant, leading_icon: bool, size: ControlSize) -> Self {
|
||||
let (_, _, border) = variant.roles();
|
||||
let pad = size.pad_x();
|
||||
let start = if leading_icon {
|
||||
ui.space(Space::X4)
|
||||
pad - ui.space(Space::X1)
|
||||
} else {
|
||||
ui.space(Space::X6)
|
||||
pad
|
||||
};
|
||||
|
||||
Self {
|
||||
height: ui.metrics().control_height,
|
||||
height: size.height_px(),
|
||||
padding: Padding {
|
||||
top: 0.0,
|
||||
right: ui.space(Space::X6),
|
||||
right: pad,
|
||||
bottom: 0.0,
|
||||
left: start,
|
||||
},
|
||||
// 图标盒固定 18(`IconSize::Button`):规格卡五档按钮的图标都没单独写字号,
|
||||
// 档位只放大容器与标签,不放大图标——那是按钮,不是 FAB。
|
||||
icon: IconSize::Button.px(),
|
||||
gap: ui.space(Space::X2),
|
||||
stroke: if border.is_some() { STROKE_PX } else { 0.0 },
|
||||
size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::Elevation;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::inkcat::size::ControlSize;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
|
||||
use crate::icons;
|
||||
@@ -56,8 +57,10 @@ use super::interaction::Interaction;
|
||||
use super::layout;
|
||||
|
||||
mod assemble;
|
||||
mod fab;
|
||||
mod geometry;
|
||||
|
||||
pub use fab::{fab, Fab};
|
||||
pub use geometry::{Geometry, IconGeometry};
|
||||
|
||||
use assemble::{assemble, style_closure, StyleSpec};
|
||||
@@ -160,35 +163,47 @@ impl Variant {
|
||||
}
|
||||
}
|
||||
|
||||
/// IconButton 的三种容器变体(12b §2.5)。
|
||||
/// IconButton 的**四式**(规格卡 `ICON BUTTONS / FILLED - TONAL - OUTLINED - PLAIN`)。
|
||||
///
|
||||
/// 规格卡画了四颗:实心 / 色调 / 描边 / 纯图标。12b §2.5 只有前三式(Standard / Filled / Tonal),
|
||||
/// M229 按规格卡把 Standard 拆成 **Plain**(纯图标)与 **Outlined**(1px 描边圆圈),
|
||||
/// `Standard` 这个名字保留为 `Plain` 的别名语义,避免既有调用点全改。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum IconVariant {
|
||||
/// 标准:容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
|
||||
Standard,
|
||||
/// 纯图标(规格卡 `PLAIN`):容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
|
||||
Plain,
|
||||
/// 实心:`Primary` + `OnPrimary`。
|
||||
Filled,
|
||||
/// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。
|
||||
Tonal,
|
||||
/// 描边:透明容器 + 1px `Outline`(规格卡第三颗,M229 新增)。
|
||||
Outlined,
|
||||
}
|
||||
|
||||
impl IconVariant {
|
||||
/// 三式全序(gallery 顺序)。
|
||||
pub const ALL: [IconVariant; 3] = [
|
||||
IconVariant::Standard,
|
||||
/// 旧名字的别名:`Standard` = [`IconVariant::Plain`](既有调用点不必改语义)。
|
||||
#[allow(non_upper_case_globals)]
|
||||
pub const Standard: IconVariant = IconVariant::Plain;
|
||||
|
||||
/// 四式全序(gallery 顺序 = 规格卡从左到右)。
|
||||
pub const ALL: [IconVariant; 4] = [
|
||||
IconVariant::Filled,
|
||||
IconVariant::Tonal,
|
||||
IconVariant::Outlined,
|
||||
IconVariant::Plain,
|
||||
];
|
||||
|
||||
/// 稳定名字。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
IconVariant::Standard => "standard",
|
||||
IconVariant::Plain => "plain",
|
||||
IconVariant::Filled => "filled",
|
||||
IconVariant::Tonal => "tonal",
|
||||
IconVariant::Outlined => "outlined",
|
||||
}
|
||||
}
|
||||
|
||||
/// **禁用态图标色**:三式都是 `OnSurface` @0.38 —— A1
|
||||
/// **禁用态图标色**:四式都是 `OnSurface` @0.38 —— A1
|
||||
/// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。
|
||||
pub(crate) const fn disabled_content(self) -> Role {
|
||||
Role::OnSurface
|
||||
@@ -197,13 +212,14 @@ impl IconVariant {
|
||||
/// 色彩角色三元组(同 [`Variant::roles`] 的口径)。
|
||||
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
|
||||
match self {
|
||||
IconVariant::Standard => (None, Role::OnSurfaceVariant, None),
|
||||
IconVariant::Plain => (None, Role::OnSurfaceVariant, None),
|
||||
IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
|
||||
IconVariant::Tonal => (
|
||||
Some(Role::SecondaryContainer),
|
||||
Role::OnSecondaryContainer,
|
||||
None,
|
||||
),
|
||||
IconVariant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::Outline)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,6 +236,8 @@ pub struct Button<'a, M> {
|
||||
leading: Option<IconKind>,
|
||||
trailing: Option<IconKind>,
|
||||
state: Option<Interaction>,
|
||||
/// 尺寸档(规格卡 `BUTTONS / XS - XL`)。产品默认 **S(40 高)**,与 M3 `control_height` 同值。
|
||||
size: ControlSize,
|
||||
}
|
||||
|
||||
impl<'a, M> Button<'a, M> {
|
||||
@@ -231,6 +249,7 @@ impl<'a, M> Button<'a, M> {
|
||||
leading: None,
|
||||
trailing: None,
|
||||
state: None,
|
||||
size: ControlSize::S,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,9 +261,19 @@ impl<'a, M> Button<'a, M> {
|
||||
leading: None,
|
||||
trailing: None,
|
||||
state: None,
|
||||
size: ControlSize::S,
|
||||
}
|
||||
}
|
||||
|
||||
/// 换尺寸档(`XS 32 / S 40 / M 56 / L 96 / XL 136`,圆角一律药丸)。
|
||||
///
|
||||
/// 产品页只用 **S**(= M3 `control_height` 40);M / L / XL 是规格卡的梯子档,给
|
||||
/// 空态主行动与展示型入口留着,`--screen=gallery` 的矩阵会把五档一起拍出来。
|
||||
pub fn size(mut self, size: ControlSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// 前置图标(12b 1.4:带图标时左内边距收到 16)。
|
||||
pub fn leading(mut self, kind: IconKind) -> Self {
|
||||
self.leading = Some(kind);
|
||||
|
||||
@@ -69,16 +69,20 @@ fn geometry_matches_the_reference_values() {
|
||||
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");
|
||||
// M229:内距取规格卡的 `ControlSize::pad_x`(S 档 = 20),不再是 12b 的 24。
|
||||
assert_eq!(geometry.padding.left, 20.0, "S 档 pad_x 20");
|
||||
assert_eq!(geometry.padding.right, 20.0, "S 档 pad_x 20");
|
||||
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, "带图标右内边距不动");
|
||||
assert_eq!(
|
||||
with_icon.padding.left, 16.0,
|
||||
"带图标左侧再收 4(20 − 4):与 12b 1.4 的 16 恰好同值"
|
||||
);
|
||||
assert_eq!(with_icon.padding.right, 20.0, "带图标右内边距不动");
|
||||
|
||||
let outlined = Geometry::of(&ui, Variant::Outlined, false);
|
||||
assert_eq!(outlined.stroke, STROKE_PX, "rubric G7 全站 1px");
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
//! | 12.2 | 线性颜色 | 激活 `Primary`、轨道 `SecondaryContainer` | 组件层取角色 |
|
||||
//! | 12.3 | 环形直径 / 厚度 / 环↔轨间隙 | **40 / 4 / 4** | [`CircularGeometry`] |
|
||||
//! | 12.4 | 不定态节奏 | 线性 **1750ms** 双段;环形 **6000ms**(弧长 0.1→0.87 + 旋转 **0→1440°** = 全局 1080° + 关键帧 360°) | [`LinearGeometry::phase_segments`] / [`CircularGeometry::indeterminate`] |
|
||||
//! | 14.1 | Snackbar 圆角 | **4**(`CornerExtraSmall`) | `Component::Snackbar.spec().shape` |
|
||||
//! | 14.3 | 单行 / 双行高 | **48 / 68** | `panel::SnackbarGeometry::height` |
|
||||
//! | 14.1 | Snackbar 圆角 | 规格卡 **16**(`inkcat::metrics::SNACKBAR`;官方 `CornerExtraSmall` 是 4,M229 换肤按规格卡) | `panel::SnackbarGeometry::radius` |
|
||||
//! | 14.3 | 单行 / 双行高 | 规格卡 **52** / **72**(+20;官方 48/68) | `panel::SnackbarGeometry::height_for` |
|
||||
//! | 14.4 | 文本水平内距 / 垂直内距 | **16** / **6** | `Space::X4` / `panel::SnackbarGeometry::pad_v` |
|
||||
//! | 14.4b | 外边距(距窗口边) | **12**(`[规格]`:AAR 里**没有**这个常数,见 G17-c) | `panel::SnackbarGeometry::margin` |
|
||||
//! | 14.x | 宽度上限 | **600**(`SnackbarKt.ContainerMaxWidth`) | `panel::SnackbarGeometry::max_width` |
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! (宽高/内距/圆角)。合在一个文件里会顶到 400 行红线(AGENTS.md §3-1)。
|
||||
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::inkcat::metrics as inkcat;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::theme::Ui;
|
||||
@@ -33,18 +34,24 @@ pub struct SnackbarGeometry {
|
||||
|
||||
impl SnackbarGeometry {
|
||||
/// 从令牌求值。
|
||||
///
|
||||
/// M229 换肤把**高与圆角**改成规格卡口径(`inkcat::metrics::SNACKBAR`:340×**52**、rx=**16**):
|
||||
/// 官方 `SnackbarTokens` 是 48 高 + `CornerExtraSmall(4)`,规格卡画成 52/16 —— M228 已把这条
|
||||
/// 差异登记为「换肤时取 `SNACKBAR`」。双行高仍按官方「每多一行 +20」推成 **72**(52 + 20),
|
||||
/// 与单行同 delta;宽上限 600 与边距 12 不动(它们不在规格卡上)。
|
||||
pub fn of(ui: &Ui) -> Self {
|
||||
let single_height = 48.0;
|
||||
let spec = inkcat::SNACKBAR;
|
||||
let single_height = spec.height;
|
||||
Self {
|
||||
min_width: 344.0,
|
||||
max_width: 600.0,
|
||||
single_height,
|
||||
two_height: 68.0,
|
||||
two_height: single_height + 20.0,
|
||||
margin: ui.space(Space::X3),
|
||||
pad_h: ui.space(Space::X4),
|
||||
pad_v: 6.0,
|
||||
gap: ui.space(Space::X2),
|
||||
radius: Component::Snackbar.spec().shape.radius_px(single_height),
|
||||
radius: spec.radius_px(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ fn indeterminate_row(shot: &Shot, active: iced::Color, surface: iced::Color) ->
|
||||
|
||||
/// §14:宽度上下限 + 单/双行高。
|
||||
fn check_snackbar(shot: &Shot, ui: &Ui) {
|
||||
// 参考值写字面量(A1):600 = SnackbarKt.ContainerMaxWidth,48/68 = SnackbarTokens。
|
||||
// 参考值写字面量:600 = SnackbarKt.ContainerMaxWidth,52/72 = 规格卡(单行 52 + 每多一行 20)。
|
||||
let surface = ui.color(Role::Surface);
|
||||
let bar = ui.color(Role::InverseSurface);
|
||||
|
||||
@@ -242,12 +242,12 @@ fn check_snackbar(shot: &Shot, ui: &Ui) {
|
||||
"宿区 324 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}"
|
||||
);
|
||||
assert!(
|
||||
heights.iter().any(|h| near(*h, 48)),
|
||||
"14.3:单行高 48,实测高度集 {heights:?}"
|
||||
heights.iter().any(|h| near(*h, 52)),
|
||||
"规格卡:单行高 52,实测高度集 {heights:?}"
|
||||
);
|
||||
assert!(
|
||||
heights.iter().any(|h| near(*h, 68)),
|
||||
"14.3:双行高 68,实测高度集 {heights:?}"
|
||||
heights.iter().any(|h| near(*h, 72)),
|
||||
"双行高 72(52 + 20),实测高度集 {heights:?}"
|
||||
);
|
||||
|
||||
// 14.6 动作色:`InversePrimary` 必须出现在提示条内(不是按钮家族的 OnSurfaceVariant)
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//!
|
||||
//! | # | 项 | 值 | 落点 |
|
||||
//! |---|---|---|---|
|
||||
//! | 14.1 | 圆角 | **4**(`CornerExtraSmall`) | `Component::Snackbar.spec().shape` |
|
||||
//! | 14.1 | 圆角 | **16**(规格卡 `rx=16`;官方 `CornerExtraSmall` 是 4,M229 换肤按规格卡) | `inkcat::metrics::SNACKBAR` |
|
||||
//! | 14.2 | 容器色 | **`InverseSurface`**(高程 L3 是设计口头径,见下「不画阴影」) | [`Role::InverseSurface`] |
|
||||
//! | 14.3 | 高 | 单行 **48** / 双行 **68** | [`SnackbarGeometry::height`] |
|
||||
//! | 14.3 | 高 | 单行 **52**(规格卡 340×52;官方 48)/ 双行 **72**(+20) | [`SnackbarGeometry::height_for`] |
|
||||
//! | 14.4 | 内距 | 水平 **16** / 垂直 **6** / 文本↔动作 **8** | `Space::X4` / 6 / `Space::X2` |
|
||||
//! | 14.6 | 颜色 | 文本 `InverseOnSurface`、动作 **`InversePrimary`**、图标 24 `InverseOnSurface` | 组件层取角色 |
|
||||
//! | — | 宽度上限 | **600**(`SnackbarKt.ContainerMaxWidth`) | `max_width` |
|
||||
@@ -75,7 +75,7 @@ pub struct Snackbar<'a, M> {
|
||||
}
|
||||
|
||||
impl<'a, M> Snackbar<'a, M> {
|
||||
/// 单行提示条(高 48)。
|
||||
/// 单行提示条(高 52,规格卡口径)。
|
||||
pub fn new(text: &'a str) -> Self {
|
||||
Self {
|
||||
text,
|
||||
|
||||
@@ -32,13 +32,18 @@ pub(super) fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
|
||||
// ---- §14 Snackbar -----------------------------------------------------------
|
||||
|
||||
/// 14.3 / 14.1 / 宽度上限:48 / 68 / 4 / 600。
|
||||
/// 14.3 / 14.1 / 宽度上限:规格卡 52 / 72 / 16,上限 600。
|
||||
#[test]
|
||||
fn snackbar_heights_widths_and_radius_are_the_tokens() {
|
||||
let geometry = SnackbarGeometry::of(&ui(SchemeFlavor::Light));
|
||||
assert_eq!(geometry.height_for(1), 48.0, "14.3 单行 48");
|
||||
assert_eq!(geometry.height_for(2), 68.0, "14.3 双行 68");
|
||||
assert_eq!(geometry.radius, 4.0, "14.1 圆角 4(CornerExtraSmall)");
|
||||
// M229 换肤:高与圆角取规格卡(52 / 16),官方是 48 / `CornerExtraSmall(4)`。
|
||||
assert_eq!(geometry.height_for(1), 52.0, "规格卡 340×52");
|
||||
assert_eq!(
|
||||
geometry.height_for(2),
|
||||
72.0,
|
||||
"双行 = 单行 + 20(同官方 delta)"
|
||||
);
|
||||
assert_eq!(geometry.radius, 16.0, "规格卡 rx=16(官方 4,登记差异)");
|
||||
assert_eq!(geometry.max_width, 600.0, "SnackbarKt.ContainerMaxWidth");
|
||||
assert_eq!(geometry.min_width, 344.0, "G17-d [估] 最小宽");
|
||||
assert!(geometry.min_width < geometry.max_width, "上下限必须有区间");
|
||||
|
||||
@@ -73,7 +73,7 @@ pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let mut body = column"
|
||||
)]
|
||||
.spacing(ui.space(Space::X3));
|
||||
@@ -270,7 +270,7 @@ fn snackbar_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
wide,
|
||||
caption(
|
||||
ui,
|
||||
"Snackbar · 双行 68 + action InversePrimary + 关闭键(动作强制 hover 态)"
|
||||
"Snackbar · 双行 72(52+20)+ action InversePrimary + 关闭键(动作强制 hover 态)"
|
||||
),
|
||||
two_lines,
|
||||
caption(
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
//! ULL_GALLERY_SECTION=selection # 只渲染 selection 段(本 mission 的取证与 probe 用它)
|
||||
//! ```
|
||||
//!
|
||||
//! 取值:`all` / `buttons` / `divider` / `selection` / `inputs` / `feedback` / `navigation`。
|
||||
//! 取值:`all` / `buttons` / `sizes` / `divider` / `selection` / `inputs` / `feedback` / `navigation`。
|
||||
//! 语义是「**不构建**没被选中的段」(不是构建后隐藏):`column` 里根本不 push 它,
|
||||
//! 于是该家族的 `view` 一行都不跑。未识别的值**回落 `all`** 并在 stderr 打一行提示
|
||||
//! (静默回落会让人拿错图还以为拍的是那一段)。
|
||||
@@ -87,6 +87,7 @@ mod probe;
|
||||
#[cfg(test)]
|
||||
mod scan;
|
||||
mod selection;
|
||||
mod sizes;
|
||||
|
||||
/// 段落过滤的环境变量名(语义见模块文档「段落过滤」)。
|
||||
pub const SECTION_ENV: &str = "ULL_GALLERY_SECTION";
|
||||
@@ -95,6 +96,7 @@ pub const SECTION_ENV: &str = "ULL_GALLERY_SECTION";
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct Sections {
|
||||
buttons: bool,
|
||||
sizes: bool,
|
||||
divider: bool,
|
||||
selection: bool,
|
||||
inputs: bool,
|
||||
@@ -106,6 +108,7 @@ impl Sections {
|
||||
/// 五段全渲染(默认)。
|
||||
const ALL: Self = Self {
|
||||
buttons: true,
|
||||
sizes: true,
|
||||
divider: true,
|
||||
selection: true,
|
||||
inputs: true,
|
||||
@@ -117,6 +120,7 @@ impl Sections {
|
||||
fn only(name: &str) -> Option<Self> {
|
||||
let mut sections = Self {
|
||||
buttons: false,
|
||||
sizes: false,
|
||||
divider: false,
|
||||
selection: false,
|
||||
inputs: false,
|
||||
@@ -125,6 +129,7 @@ impl Sections {
|
||||
};
|
||||
match name {
|
||||
"buttons" => sections.buttons = true,
|
||||
"sizes" => sections.sizes = true,
|
||||
"divider" => sections.divider = true,
|
||||
"selection" => sections.selection = true,
|
||||
"inputs" => sections.inputs = true,
|
||||
@@ -150,7 +155,7 @@ impl Sections {
|
||||
return sections;
|
||||
}
|
||||
eprintln!(
|
||||
"[gallery] {SECTION_ENV}={raw:?} 不是已知段(all|buttons|divider|selection|inputs|feedback|navigation),已回落 all"
|
||||
"[gallery] {SECTION_ENV}={raw:?} 不是已知段(all|buttons|sizes|divider|selection|inputs|feedback|navigation),已回落 all"
|
||||
);
|
||||
Self::ALL
|
||||
}
|
||||
@@ -160,6 +165,7 @@ impl Sections {
|
||||
let mut names = Vec::new();
|
||||
for (on, name) in [
|
||||
(self.buttons, "buttons"),
|
||||
(self.sizes, "sizes"),
|
||||
(self.divider, "divider"),
|
||||
(self.selection, "selection"),
|
||||
(self.inputs, "inputs"),
|
||||
@@ -215,8 +221,9 @@ pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
.font(ui.emphasized_font(TypeRole::TitleLarge))
|
||||
.color(ui.color(Role::OnSurface)),
|
||||
text(format!(
|
||||
"M3 Expressive component base · flavor={} · 1180×760 @100% DPI · \
|
||||
"M3 Expressive component base · board={} · flavor={} · 1180×760 @100% DPI · \
|
||||
focus 与 pressed 同为 10%(令牌口径,图上一样是对的)",
|
||||
ui.board().name(),
|
||||
flavor_slug(ui)
|
||||
))
|
||||
.size(page.size_px)
|
||||
@@ -236,6 +243,9 @@ pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
if picked.buttons {
|
||||
sections = sections.push(buttons::section(ui));
|
||||
}
|
||||
if picked.sizes {
|
||||
sections = sections.push(sizes::section(ui));
|
||||
}
|
||||
if picked.divider {
|
||||
sections = sections.push(divider_proof(ui));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! 尺寸档矩阵:**按钮 XS~XL 五档** + **FAB 四态**(规格卡 `BUTTONS / XS - XL` 与
|
||||
//! `FAB / SMALL - DEFAULT - MEDIUM - EXTENDED`)。
|
||||
//!
|
||||
//! ## 为什么单开一段(而不是塞进 `buttons`)
|
||||
//!
|
||||
//! `buttons` 段是「五式 × 五态 + 图标四式」的 40 格矩阵,一屏(1180×760)本来就贴着折叠线;
|
||||
//! 五档梯子(最高 136)与四态 FAB(最宽 160)塞进去会被压到屏幕外——那样出的图**看不见**,
|
||||
//! 等于没拍。所以按「一段 = 一个能一屏判完的问题」拆开:
|
||||
//!
|
||||
//! ```text
|
||||
//! ULL_GALLERY_SECTION=sizes # 本段:尺寸梯子 + FAB 四态
|
||||
//! ```
|
||||
//!
|
||||
//! ## 图上要能判什么
|
||||
//!
|
||||
//! 1. 五档高度递增、圆角全部是**半高**(药丸),标签字号跟着档位升(14/14/16/24/32);
|
||||
//! 2. FAB 四态:40/56/80 三个方态 + 160×56 扩展态(图标 + 标签);
|
||||
//! 3. 配色:FAB 一律 `PrimaryContainer` / `OnPrimaryContainer`(规格卡上四颗都是淡绿底深绿字)。
|
||||
//!
|
||||
//! 判不了:状态层(FAB 在这一段是静息态,五态矩阵在 `buttons` 段的按钮上验)、以及动效。
|
||||
|
||||
use iced::widget::{column, row};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::inkcat::size::{ControlSize, FabSize};
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::buttons::{self, fab, Button, Fab};
|
||||
use super::super::interaction::Interaction;
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 整段(标题 + 五档梯子 + 四态 FAB + 图例)。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let legend = ui.style(TypeRole::BodySmall);
|
||||
|
||||
let ladder = row(ControlSize::ALL
|
||||
.iter()
|
||||
.map(|size| sized(ui, *size))
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(ui.space(Space::X4))
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let fabs = row(FabSize::ALL
|
||||
.iter()
|
||||
.map(|size| fab_cell(ui, *size))
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(ui.space(Space::X4))
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
column![
|
||||
heading(
|
||||
ui,
|
||||
"sizes · buttons xs~xl + fab small~extended",
|
||||
"规格卡:按钮 32/40/56/96/136(圆角 = 半高,标签 14/14/16/24/32 字重 500);\
|
||||
FAB 40/r12 · 56/r16 · 80/r20 · 160×56/r16(图标 20/26/34/24,扩展态内距 28、间距 8)"
|
||||
),
|
||||
labelled(ui, "buttons xs - xl", ladder.into()),
|
||||
labelled(ui, "fab small - extended", fabs.into()),
|
||||
iced::widget::text(
|
||||
"图例:产品页的按钮只用 S 档(40,= M3 control_height);M/L/XL 留给空态主行动与展示型入口。\
|
||||
FAB 四颗都是静息态,配色 PrimaryContainer / OnPrimaryContainer。",
|
||||
)
|
||||
.size(legend.size_px)
|
||||
.font(ui.font_of(legend))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一档 Filled 按钮(标签是档位名的大写:XS / S / M / L / XL。
|
||||
///
|
||||
/// **必须大写**:小写的 `l` 在 Inter/Segoe 里就是一根竖线,XL 会读成「x|」——第一版出图就是这么错的。
|
||||
fn sized<'a>(ui: &'a Ui, size: ControlSize) -> Element<'a, DemoMessage> {
|
||||
let label: &'static str = match size {
|
||||
ControlSize::Xs => "XS",
|
||||
ControlSize::S => "S",
|
||||
ControlSize::M => "M",
|
||||
ControlSize::L => "L",
|
||||
ControlSize::Xl => "XL",
|
||||
};
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(label, DemoMessage::Pressed("size")).size(size),
|
||||
)
|
||||
}
|
||||
|
||||
/// 一颗 FAB(扩展态带标签;静息态强制,避免光标位置影响出图)。
|
||||
fn fab_cell<'a>(ui: &'a Ui, size: FabSize) -> Element<'a, DemoMessage> {
|
||||
let spec = if matches!(size, FabSize::Extended) {
|
||||
Fab::extended(IconKind::Plus, "New", DemoMessage::Pressed("fab"))
|
||||
} else {
|
||||
Fab::new(IconKind::Plus, DemoMessage::Pressed("fab"))
|
||||
}
|
||||
.size(size)
|
||||
.state(Interaction::Enabled);
|
||||
fab(ui, spec)
|
||||
}
|
||||
|
||||
/// 一行「小标题 + 内容」。
|
||||
fn labelled<'a>(
|
||||
ui: &'a Ui,
|
||||
label: &'a str,
|
||||
content: Element<'a, DemoMessage>,
|
||||
) -> Element<'a, DemoMessage> {
|
||||
let style = ui.style(TypeRole::LabelSmall);
|
||||
column![
|
||||
iced::widget::text(label)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
iced::widget::container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(iced::Padding::from([ui.space(Space::X2), 0.0])),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.into()
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
//! inputs/ ← 同上(§9/§20/§11/§8)
|
||||
//! feedback/ ← 同上(§12/§14/§7/§15)
|
||||
//! navigation/ ← 同上(§4/§6/§21/§16)
|
||||
//! surfaces/ ← M229:ELEVATED CARD(规格卡 `CARD / DIALOG / SNACKBAR / LIST` 的第一格)
|
||||
//! gallery/ ← 视觉验收 harness(`--screen=gallery`)
|
||||
//! ```
|
||||
//!
|
||||
@@ -58,6 +59,7 @@ pub mod motion;
|
||||
pub mod navigation;
|
||||
pub mod scroll;
|
||||
pub mod selection;
|
||||
pub mod surfaces;
|
||||
|
||||
#[cfg(test)]
|
||||
mod gate;
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
use unlucky_design::component_map::{Component, ComponentMetrics};
|
||||
use unlucky_design::icons::IconSize;
|
||||
use unlucky_design::inkcat::metrics as inkcat;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::theme::Ui;
|
||||
@@ -63,40 +64,39 @@ pub struct ControlGeometry {
|
||||
}
|
||||
|
||||
impl ControlGeometry {
|
||||
/// Checkbox:20×20 / 圆角 2 / 描边 2 / 状态层 Ø40(全部 `[源]`,见模块表)。
|
||||
/// Checkbox:**18×18** / 圆角 **4** / 描边 2 / 状态层 Ø40(M229 取规格卡口径,见模块表)。
|
||||
///
|
||||
/// 与官方 20×20 / 2dp 差 2px 与 2dp:规格卡画的是 `rect 18×18 rx4`,M228 的 `inkcat` 把这条
|
||||
/// 差异登记为「换肤时取 `CHECKBOX`」。4 是 `ShapeScale` 的刻度值,所以走容器层不违反 D32。
|
||||
pub fn checkbox(ui: &Ui) -> Self {
|
||||
let metrics = metrics(Component::Checkbox);
|
||||
let spec = inkcat::CHECKBOX;
|
||||
Self {
|
||||
side: metrics.width,
|
||||
stroke: metrics.stroke,
|
||||
side: spec.width,
|
||||
stroke: inkcat::CHECKBOX_STROKE,
|
||||
state_layer: metrics.state_layer,
|
||||
hit: ui.metrics().touch_target,
|
||||
gap: metrics.gap,
|
||||
// 微控件层(D32):2dp 不在 `ShapeScale` 刻度里,只能走 `ComponentRadius`。
|
||||
// 传 20 拿不到值说明有人把它挪到容器层了——那是 D32 违规,`unwrap_or` 会悄悄
|
||||
// 变成直角,所以这里用 `expect` 直接炸(比图画错好查)。
|
||||
radius: metrics
|
||||
.radius
|
||||
.expect("Checkbox 的圆角必须在微控件层(ComponentSpec::micro_radius)")
|
||||
.radius_px(metrics.width)
|
||||
.expect("Checkbox 20dp ≤ 微控件上限 24dp"),
|
||||
radius: spec.radius_px(),
|
||||
// Checkbox 没有内点(内点是 radio 的语言,§19.6 明令两者不许混用)
|
||||
inner_dot: metrics.inner_dot,
|
||||
inner_dot: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Radio:20 / 环线 2 / 内点 10 / 状态层 Ø40;圆角是**真圆**(`ShapeScale::Full`)。
|
||||
/// Radio:**18 Ø** / 环线 2 / 内点 **10** / 状态层 Ø40;圆角是**真圆**(`ShapeScale::Full`)。
|
||||
pub fn radio(ui: &Ui) -> Self {
|
||||
let metrics = metrics(Component::Radio);
|
||||
let spec = inkcat::RADIO;
|
||||
Self {
|
||||
side: metrics.width,
|
||||
stroke: metrics.stroke,
|
||||
side: spec.width,
|
||||
stroke: inkcat::RADIO_RING,
|
||||
state_layer: metrics.state_layer,
|
||||
hit: ui.metrics().touch_target,
|
||||
gap: metrics.gap,
|
||||
radius: Component::Radio.spec().shape.radius_px(metrics.width),
|
||||
// 内点 **10 不是 12**(§19.2:12 是含描边前的名义值,绘制半径 = 12/2 − 2/2 = 5)
|
||||
inner_dot: metrics.inner_dot,
|
||||
radius: spec.radius_px(),
|
||||
// 内点 **10**(§19.2:12 是含描边前的名义值,绘制半径 = 12/2 − 2/2 = 5)。
|
||||
// 规格卡实测同样是 10,所以这里取 `inkcat::RADIO_DOT`。
|
||||
inner_dot: inkcat::RADIO_DOT,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,15 +45,16 @@ fn hit_area_is_48_square_even_without_a_label() {
|
||||
fn checkbox_geometry_comes_from_the_token_layer() {
|
||||
let ui = ui();
|
||||
let g = ControlGeometry::checkbox(&ui);
|
||||
assert_eq!(g.side, 20.0, "18.1:容器 20×20(ContainerWidth=18 是死值)");
|
||||
assert_eq!(g.radius, 2.0, "18.1:圆角 2(ComponentRadius::Micro)");
|
||||
// M229:取规格卡的 18×18 / rx4(官方 20×20 / 2dp,登记在 `inkcat` 模块)。
|
||||
assert_eq!(g.side, 18.0, "规格卡 rect 18×18");
|
||||
assert_eq!(g.radius, 4.0, "规格卡 rx=4(ShapeScale::ExtraSmall)");
|
||||
assert_eq!(g.stroke, 2.0, "18.2:未选中描边 2px");
|
||||
assert_eq!(g.state_layer, 40.0, "18.5:状态层 Ø40");
|
||||
assert_eq!(g.hit, 48.0, "18.9:命中区 48");
|
||||
assert_eq!(g.gap, 8.0, "18.9:框↔标签 8([估] 团队口径)");
|
||||
assert_eq!(g.inner_dot, 0.0, "Checkbox 没有内点");
|
||||
// 18.4:勾形横向端点跨度 = 0.8−0.2 = 0.6 × 20 = 12
|
||||
assert!((g.check_span() - 12.0).abs() < 0.001, "18.4:勾横向 12dp");
|
||||
// 18.4:勾形横向端点跨度 = 0.8−0.2 = 0.6 × 18 = 10.8(比例不变,边长换成规格卡的 18)
|
||||
assert!((g.check_span() - 10.8).abs() < 0.001, "18.4:勾横向 10.8dp");
|
||||
// 方头外扩:每端 2/√2 ≈ 1.41 → 墨迹跨度 ≈ 14.8(参考值 12 是端点跨度,不是墨迹)
|
||||
let bleed = g.square_cap_bleed();
|
||||
assert!(
|
||||
@@ -61,8 +62,8 @@ fn checkbox_geometry_comes_from_the_token_layer() {
|
||||
"方头外扩 1/√2×线宽"
|
||||
);
|
||||
assert!(
|
||||
(g.check_span() + bleed * 2.0 - 14.83).abs() < 0.05,
|
||||
"墨迹跨度 ≈14.83"
|
||||
(g.check_span() + bleed * 2.0 - 13.63).abs() < 0.05,
|
||||
"墨迹跨度 ≈13.63(10.8 + 2×1.41)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,9 +72,10 @@ fn checkbox_geometry_comes_from_the_token_layer() {
|
||||
fn radio_inner_dot_is_ten_not_twelve() {
|
||||
let ui = ui();
|
||||
let g = ControlGeometry::radio(&ui);
|
||||
assert_eq!(g.side, 20.0, "19.1:外圈直径 20");
|
||||
// M229:外圈取规格卡的 18 Ø(官方 20 Ø,登记在 `inkcat` 模块)。
|
||||
assert_eq!(g.side, 18.0, "规格卡 r=9 ⇒ 直径 18");
|
||||
assert_eq!(g.stroke, 2.0, "19.1:环线 2px");
|
||||
assert_eq!(g.radius, 10.0, "19.1:真圆(半径 = 边长/2)");
|
||||
assert_eq!(g.radius, 9.0, "19.1:真圆(半径 = 边长/2)");
|
||||
assert_eq!(g.inner_dot, 10.0, "19.2:内点可视直径 10");
|
||||
assert_ne!(
|
||||
g.inner_dot, 12.0,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! **ELEVATED CARD**(规格卡 `CARD / DIALOG / SNACKBAR / LIST` 的第一个方框)。
|
||||
//!
|
||||
//! ## 规格卡原值(`DevDocs/artifacts/components-ink.svg`,`[spec]`)
|
||||
//!
|
||||
//! | 项 | 值 | 出处 |
|
||||
//! |---|---|---|
|
||||
//! | 方框 | 300×150、**rx=16**(`inkcat::metrics::CARD`) | `rect x=64 y=1312 rx=16` |
|
||||
//! | 底色 | **`SurfaceContainer`**(Ink 板上比画布 `Surface` 亮一档) | 规格卡卡底那一格的 `fill` |
|
||||
//! | 内边距 | **24**(文字 `x=88` − 框 `x=64`) | 同上 |
|
||||
//! | 描边 | **无**(`stroke="none"`) | 同上 |
|
||||
//! | 上标注 | **11 / 600**(`LabelSmall` + 半粗,字距 1) | `ELEVATED CARD` 那行 |
|
||||
//! | 标题 | **16 / 400**(`BodyLarge`) | `Night bridge, bright rails.` 那行 |
|
||||
//! | 副文案 | 13 / 400 → **14**(`BodyMedium`;13 落在 12/14 之间,按 `inkcat::type_scale` 取较大档) | `night / bridge / cat` 那行 |
|
||||
//!
|
||||
//! ## 与 `pages::frame::card`(描边卡)的分工
|
||||
//!
|
||||
//! - [`card`]:**靠层级差站立**的卡(无描边、`SurfaceContainer` 底、16 圆角)。规格卡的
|
||||
//! `ELEVATED CARD` 就是这一档,实例页的元信息卡用它;
|
||||
//! - `pages::frame::card`:描边卡(`OutlineVariant` 1px + 卡片层底),大面积表单容器用它。
|
||||
//!
|
||||
//! 两种卡并存是**有意的**:规格卡只画了 elevated 一种,而桌面端的长表单需要「弱一点的容器」
|
||||
//! (满屏都是 16 圆角 + 容器底,分隔线会看起来像浮块)。哪种卡用在哪儿由本节决定,不是随手挑。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{column, container, text};
|
||||
use iced::{Background, Border, Element, Length, Padding, Pixels};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::{TypeRole, TypeStyle, Weight};
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// ELEVATED CARD 的内边距(规格卡 88 − 64 = **24**)。
|
||||
///
|
||||
/// 与 `LayoutMetrics::card_padding`(描边卡的 21)不是一回事:那个数服务长表单,这个是规格卡量出来的。
|
||||
pub const PAD: f32 = 24.0;
|
||||
|
||||
/// 一个 ELEVATED CARD 方框(只管壳:底、圆角、内边距;**无描边**)。
|
||||
///
|
||||
/// 消息类型与被包的内容同型,所以它能被任何页面复用(实例页 / 外观页 / 账户页各用自己的
|
||||
/// `Message`)——这是「组件不 import 业务消息」那条契约的落地方式。
|
||||
pub fn card<'a, M: 'a>(ui: &'a Ui, content: impl Into<Element<'a, M>>) -> Element<'a, M> {
|
||||
let shape = ui.card_shape();
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([PAD, PAD]))
|
||||
.style(move |_theme| container::Style {
|
||||
// 规格卡的卡底是 **surface-container**(不是 `surface-container-low`):
|
||||
// 层级靠与画布(`surface`)的 tone 差表达,不靠描边。
|
||||
background: Some(Background::Color(ui.color(Role::SurfaceContainer))),
|
||||
border: Border {
|
||||
radius: ui.radius_dp(shape),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一张规格卡的三段文字:上标注(可选)/ 标题 / 副文案(可选)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Texts<'a> {
|
||||
/// 上标注(`ELEVATED CARD` 那种小字标签)。
|
||||
pub overline: Option<&'a str>,
|
||||
/// 标题(16/400)。
|
||||
pub title: &'a str,
|
||||
/// 副文案(14/400)。
|
||||
pub support: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// 三段式内容(排版按规格卡:11/600 + 16/400 + 14/400,行间 4)。
|
||||
pub fn texts<'a, M: 'a>(ui: &'a Ui, parts: Texts<'a>) -> Element<'a, M> {
|
||||
let mut stack = column![].spacing(ui.space(Space::X1));
|
||||
|
||||
if let Some(overline) = parts.overline {
|
||||
stack = stack.push(role_text(
|
||||
ui,
|
||||
overline_style(),
|
||||
overline,
|
||||
Role::OnSurfaceVariant,
|
||||
));
|
||||
}
|
||||
stack = stack.push(role_text(
|
||||
ui,
|
||||
ui.style(TypeRole::BodyLarge),
|
||||
parts.title,
|
||||
Role::OnSurface,
|
||||
));
|
||||
if let Some(support) = parts.support {
|
||||
stack = stack.push(role_text(
|
||||
ui,
|
||||
ui.style(TypeRole::BodyMedium),
|
||||
support,
|
||||
Role::OnSurfaceVariant,
|
||||
));
|
||||
}
|
||||
|
||||
stack.width(Length::Fill).into()
|
||||
}
|
||||
|
||||
/// 上标注的排版(`LabelSmall` 11/16,字重 **600**:规格卡 `font-weight=600`;
|
||||
/// M3 的 `LabelSmall` 是 500,`emphasized` 是 700 —— 两个都不是 600,所以在这里显式给 600)。
|
||||
pub fn overline_style() -> TypeStyle {
|
||||
TypeStyle {
|
||||
weight: Weight::SemiBold,
|
||||
..TypeRole::LabelSmall.style()
|
||||
}
|
||||
}
|
||||
|
||||
fn role_text<'a, M: 'a>(
|
||||
ui: &'a Ui,
|
||||
style: TypeStyle,
|
||||
content: &'a str,
|
||||
role: Role,
|
||||
) -> Element<'a, M> {
|
||||
text(content)
|
||||
.size(style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(role))
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,68 @@
|
||||
//! ELEVATED CARD 的几何/排版断言(对着规格卡原值,不看渲染结果)。
|
||||
//!
|
||||
//! 抓得住:圆角退回组件表的 12、内边距被换成 `card_padding`、上标注变成 500/700 这些「悄悄改数」。
|
||||
//! 抓不住:底色对不对(要看 `Ui::color(SurfaceContainer)` 的来源,那条由 `theme` 的色板测试守)、
|
||||
//! 以及卡在页面里好不好看(D30 视觉判定)。
|
||||
|
||||
use super::*;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
|
||||
const FAMILIES: crate::theme::Families = crate::theme::Families {
|
||||
text: "Segoe UI Variable Text",
|
||||
display: "Segoe UI Variable Display",
|
||||
mono: "Cascadia Mono",
|
||||
};
|
||||
|
||||
fn ui(flavor: unlucky_design::color::SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(
|
||||
flavor,
|
||||
unlucky_design::typography::Platform::Windows,
|
||||
FAMILIES,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_card_takes_the_spec_corner_and_padding() {
|
||||
let ui = ui(unlucky_design::color::SchemeFlavor::Dark);
|
||||
assert_eq!(ui.card_shape(), ShapeScale::Large, "规格卡 rx=16");
|
||||
assert_eq!(ui.card_box().radius_px(), 16.0);
|
||||
assert_eq!(PAD, 24.0, "规格卡文字 x=88 − 框 x=64");
|
||||
// 4px 栅格(`spacing::GRID`)上:24 是,21(描边卡的 card_padding)不是——两者不许混用。
|
||||
assert_eq!(PAD % unlucky_design::spacing::GRID, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_typography_matches_the_spec_rows() {
|
||||
let style = overline_style();
|
||||
assert_eq!(style.size_px, 11.0, "上标注 11px");
|
||||
assert_eq!(style.weight, Weight::SemiBold, "上标注 600(不是 500/700)");
|
||||
|
||||
let title = TypeRole::BodyLarge.style();
|
||||
assert_eq!(title.size_px, 16.0, "标题 16px");
|
||||
assert_eq!(title.weight, Weight::Regular, "标题 400");
|
||||
|
||||
let support = TypeRole::BodyMedium.style();
|
||||
assert_eq!(support.size_px, 14.0, "副文案 13 → 取较大档 14");
|
||||
assert_eq!(support.weight, Weight::Regular);
|
||||
}
|
||||
|
||||
/// 卡片是**靠层级差站立**的:底色必须是 `SurfaceContainer`(画布上一档),不是画布色本身。
|
||||
#[test]
|
||||
fn the_card_surface_sits_one_step_above_the_canvas() {
|
||||
for board in unlucky_design::color::BrandBoard::ALL {
|
||||
let scheme = board.scheme();
|
||||
assert_ne!(
|
||||
board.get(Role::SurfaceContainer),
|
||||
board.get(Role::Surface),
|
||||
"{}:卡底与画布同色,卡会「消失」",
|
||||
board.name()
|
||||
);
|
||||
// 无描边:卡的存在感只能来自这一档色差,所以必须 > 1.0(差多少属观感,D30 判)。
|
||||
let card_vs_canvas = scheme.contrast(Role::SurfaceContainer, Role::Surface);
|
||||
assert!(
|
||||
card_vs_canvas > 1.0,
|
||||
"{}:卡底对画布 {card_vs_canvas:.3}:1,看不出层级",
|
||||
board.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! 面类家族(目前只有 **ELEVATED CARD**,规格卡 `CARD / DIALOG / SNACKBAR / LIST` 的第一格)。
|
||||
//!
|
||||
//! 为什么单开一个家族而不是塞进 `feedback`:`feedback` 是「系统对用户动作的回应」(进度 / 对话框 /
|
||||
//! snackbar / 空态),卡是**静态容器**,语义上属于版式而不是反馈。家族边界写在这里,后来加
|
||||
//! 「带描边卡 / 列表行药丸」时按语义归位,不要因为「都在同一个规格卡小节里」就合进 `feedback`。
|
||||
|
||||
pub mod elevated;
|
||||
@@ -11,7 +11,6 @@ use iced::widget::{container, image, row, text};
|
||||
use iced::{Alignment, Background, Border, ContentFit, Element, Length, Pixels};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
@@ -171,7 +170,7 @@ pub fn alert<'a>(
|
||||
.padding(ui.metrics().card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||||
border: ui.outlined(Component::OutlinedCard.spec().shape),
|
||||
border: ui.outlined(ui.card_shape()),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
|
||||
@@ -59,7 +59,6 @@ use iced::{
|
||||
};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
@@ -100,7 +99,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Mess
|
||||
|
||||
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
|
||||
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
let spec = Component::OutlinedCard.spec();
|
||||
let shape = ui.card_shape();
|
||||
let background = ui.layer(MaterialLayer::Card);
|
||||
|
||||
container(content)
|
||||
@@ -108,7 +107,7 @@ fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a,
|
||||
.padding(ui.metrics().card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(background)),
|
||||
border: ui.outlined(spec.shape),
|
||||
border: ui.outlined(shape),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
|
||||
@@ -32,15 +32,19 @@ pub const SCROLLBAR_WIDTH: f32 = 6.0;
|
||||
/// 滑块离卡片内边的留白(2px,避免贴边)。
|
||||
pub const SCROLLBAR_MARGIN: f32 = 2.0;
|
||||
|
||||
/// 描边卡片(占位:M75 的 `components` 会提供真正的 `OutlinedCard`)。
|
||||
/// 描边卡片(长表单容器)。圆角取**规格卡**的 16(`Ui::card_shape`),不是组件表的 12。
|
||||
///
|
||||
/// M229 换肤:规格卡 `CARD` 小节把卡片画成 `rx=16`,而 `component_map` 的 `OutlinedCard` 是
|
||||
/// `Medium(12)`(官方值)。M228 把这条差异登记为「换肤时取 `inkcat::metrics::CARD`」,这里就是那次取值;
|
||||
/// 组件表的值不动(它还服务别的面)。描边仍走 `OutlineVariant` 1px。
|
||||
pub fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
let spec = Component::OutlinedCard.spec();
|
||||
let shape = ui.card_shape();
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.metrics().card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||||
border: ui.outlined(spec.shape),
|
||||
border: ui.outlined(shape),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
|
||||
@@ -36,13 +36,15 @@ fn empty_state_follows_the_12b_reference() {
|
||||
const { assert!(EMPTY_ICON_GLYPH < EMPTY_ICON_BLOCK) };
|
||||
}
|
||||
|
||||
/// 卡片圆角取**规格卡**的 16(M229 换肤),组件表 `OutlinedCard` 的 12 只作为对照保留。
|
||||
#[test]
|
||||
fn card_uses_the_outlined_card_spec() {
|
||||
let spec = Component::OutlinedCard.spec();
|
||||
assert_eq!(spec.shape, ShapeScale::Medium);
|
||||
assert_eq!(ui().radius_dp(spec.shape).top_left, 12.0);
|
||||
assert_eq!(spec.text, TypeRole::TitleMedium);
|
||||
assert_eq!(spec.support, Some(TypeRole::BodySmall));
|
||||
fn card_uses_the_spec_corner_not_the_component_table() {
|
||||
let ui = ui();
|
||||
assert_eq!(ui.card_shape(), ShapeScale::Large);
|
||||
assert_eq!(ui.radius_dp(ui.card_shape()).top_left, 16.0);
|
||||
// 组件表的官方值仍是 12(表不动,差异登记在 `inkcat` 模块与 M229 报告里)。
|
||||
assert_eq!(Component::OutlinedCard.spec().shape, ShapeScale::Medium);
|
||||
assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,6 +15,7 @@ use unlucky_presenter::router::Route;
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::divider;
|
||||
use crate::app::components::interaction::Interaction;
|
||||
use crate::app::components::surfaces::elevated;
|
||||
use crate::theme::Ui;
|
||||
use unlucky_design::icons::IconKind;
|
||||
|
||||
@@ -229,26 +230,18 @@ fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail) -> Eleme
|
||||
for chunk in detail.meta.chunks(columns) {
|
||||
let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill);
|
||||
for cell in chunk {
|
||||
let card = frame::card(
|
||||
// M229:元信息卡 = 规格卡的 **ELEVATED CARD**(无描边、`SurfaceContainer` 底、
|
||||
// 16 圆角、内距 24;上标注 11/600 + 值 16/400)。换肤前这里是描边卡。
|
||||
let card = elevated::card(
|
||||
ui,
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
cell.label.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
cell.value.as_str(),
|
||||
ui.color(Role::OnSurface)
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
elevated::texts(
|
||||
ui,
|
||||
elevated::Texts {
|
||||
overline: Some(cell.label.as_str()),
|
||||
title: cell.value.as_str(),
|
||||
support: None,
|
||||
},
|
||||
),
|
||||
);
|
||||
cells_row = cells_row.push(container(card).width(Length::Fill));
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
use iced::widget::{container, scrollable};
|
||||
use iced::{Background, Element, Length};
|
||||
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
|
||||
use crate::theme::Ui;
|
||||
@@ -33,7 +32,7 @@ pub fn card<'a>(
|
||||
height: Length,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let shape = Component::OutlinedCard.spec().shape;
|
||||
let shape = ui.card_shape();
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! 同一条纪律);app 层只做一次 `.map`。
|
||||
|
||||
use unlucky_i18n::LangChoice;
|
||||
use unlucky_presenter::prefs::ThemeMode;
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use super::appearance;
|
||||
@@ -29,6 +30,11 @@ pub enum Message {
|
||||
Launch(String),
|
||||
/// 停止某实例(app 层翻成 `AppCommand::StopInstance`)。
|
||||
Stop(String),
|
||||
/// 切主题档(M229:设置页「通用 → 主题」的 Ink / Silver / 跟随系统三档)。
|
||||
///
|
||||
/// 载荷是 `ThemeMode` 而不是色板枚举:Ink / Silver 就是 `dark` / `light` 两侧的**产品名**,
|
||||
/// 第三档 `auto` 由系统深浅落到对应色板。落盘键仍是 `appearance.theme_mode`。
|
||||
Theme(ThemeMode),
|
||||
/// 外观项改动(设置页「外观」分组 → `App` 的状态与落盘)。
|
||||
///
|
||||
/// 载荷是**用户请求值**(未钳位):钳位与 CPU 档降级在 `appearance::alpha` 里做,
|
||||
|
||||
@@ -29,9 +29,11 @@
|
||||
//! 3. **文案全部走 catalog**([`Key`]),三档选项名跟着界面语言走(见 `catalog/general.rs`
|
||||
//! 的头注释:为什么不写语言自称)。
|
||||
//!
|
||||
//! ## 其余三行仍只读
|
||||
//! ## 主题行已接真控件(M229),另两行仍只读
|
||||
//!
|
||||
//! 主题 / 启动页 / 关闭按钮还没有后端(`pages::Action` 里是占位动作),所以它们是**只读行**;
|
||||
//! 主题三档(Ink / Silver / 跟随系统)在 M229 接成真控件:点一下换色板 + 落盘
|
||||
//! `appearance.theme_mode`(实现在 `general/theme.rs`,消息是 `pages::Message::Theme`)。
|
||||
//! 启动页 / 关闭按钮还没有后端(`pages::Action` 里是占位动作),仍是**只读行**;
|
||||
//! 但标签与取值已经进 catalog,中文界面下不会再冒出英文。
|
||||
//!
|
||||
//! 分组元数据只有一份(这里的 [`GROUP`]):`settings/mod.rs` 的 `GROUPS` 直接引用它。
|
||||
@@ -41,7 +43,6 @@ use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Them
|
||||
|
||||
use unlucky_design::color::{Role, StateLayer};
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
@@ -52,16 +53,18 @@ use crate::pages::{frame, Language, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::frame::{card, divider};
|
||||
use super::Group;
|
||||
use super::{Group, States};
|
||||
|
||||
mod theme;
|
||||
|
||||
/// General 分组(左列名固定英文:左列是**导航**,与导航轨同源,不进 catalog)。
|
||||
pub(super) const GROUP: Group = Group {
|
||||
name: "General",
|
||||
icon: IconKind::Settings,
|
||||
// 只给 harness(`pages_demo` 走 `appearance: None` 时)用的演示行:产品路径走 [`body`]。
|
||||
// 语言那一行**不在**这里——它的值是要跟着语言变的,写死在静态表里就是用户看到的那条假值。
|
||||
// 语言与主题那两行**不在**这里——它们的值跟着语言/档位变,写死在静态表里就是用户看到的假值
|
||||
// (语言 M106 移出、主题 M229 移出)。
|
||||
rows: &[
|
||||
("Theme", "Follow system"),
|
||||
("Startup page", "Instances"),
|
||||
("Close button", "Exit to tray"),
|
||||
],
|
||||
@@ -69,7 +72,7 @@ pub(super) const GROUP: Group = Group {
|
||||
};
|
||||
|
||||
/// 这一组的正文(**产品路径**)。
|
||||
pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
pub(super) fn body<'a>(ui: &'a Ui, states: States<'a>, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
let mut stacked = column![].spacing(0.0).width(Length::Fill);
|
||||
stacked = stacked.push(language_row(ui, language));
|
||||
@@ -78,14 +81,22 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(font_row(ui, language));
|
||||
|
||||
// M188:日志文件夹(路径 + 打开 + 复制路径)。放在「主题/启动页」之前——用户投诉
|
||||
// M229:主题行接真控件(Ink / Silver / 跟随系统)。档位住在设置组的自有状态里
|
||||
// (`language_appearance::State::theme`),与语言行同一个排版:标题 + 三枚互斥 chip + 一句说明。
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(theme::view(
|
||||
ui,
|
||||
&catalog,
|
||||
states.groups.language_appearance.theme,
|
||||
));
|
||||
|
||||
// M188:日志文件夹(路径 + 打开 + 复制路径)。放在「启动页/关闭按钮」之前——用户投诉
|
||||
// 「找不到崩溃日志」要的答案,不该埋在只读行后面。
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(super::logs_row::folder_row(ui, &catalog));
|
||||
|
||||
// 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。
|
||||
// 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。
|
||||
for (label, value) in [
|
||||
(Key::SettingsTheme, Key::SettingsThemeFollowSystem),
|
||||
(Key::SettingsStartupPage, Key::SettingsStartupInstances),
|
||||
(Key::SettingsCloseButton, Key::SettingsCloseToTray),
|
||||
] {
|
||||
@@ -290,14 +301,20 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me
|
||||
|
||||
/// 一个互斥按钮(选中态 `SecondaryContainer`,未选中透明 + `OnSurfaceVariant`)。
|
||||
///
|
||||
/// 与 `pages/appearance/section/mod.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。
|
||||
/// 与 `pages/appearance/section/mod.rs` 的 `choice` 同款几何,但文案来自 catalog(那份是英文基线)。
|
||||
///
|
||||
/// **几何(M229)**:取规格卡的 chip(高 32、圆角 8、内距 16、未选中 1px `OutlineVariant`),
|
||||
/// 与同屏的主题三档 chips 完全同源——两行都是「并排的互斥筛选」,长得不一样会让读者以为是
|
||||
/// 两种控件(M229 第一版就是这样:语言 40 高药丸、主题 32 高方角,出图一眼就不齐)。
|
||||
fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> Element<'a, Message> {
|
||||
let height = ui.metrics().control_height;
|
||||
let box_ = unlucky_design::inkcat::metrics::CHIP;
|
||||
let height = box_.height;
|
||||
let content_color = if selected {
|
||||
ui.color(Role::OnSecondaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
let border_color = ui.color(Role::OutlineVariant);
|
||||
|
||||
button(
|
||||
container(frame::text_role(
|
||||
@@ -310,7 +327,10 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> El
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X3)]))
|
||||
.padding(Padding::from([
|
||||
0.0,
|
||||
unlucky_design::inkcat::metrics::CHIP_PAD_X,
|
||||
]))
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = if selected {
|
||||
Some(Background::Color(ui.color(Role::SecondaryContainer)))
|
||||
@@ -328,8 +348,13 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> El
|
||||
background,
|
||||
text_color: content_color,
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, height),
|
||||
..Border::default()
|
||||
color: border_color,
|
||||
width: if selected {
|
||||
0.0
|
||||
} else {
|
||||
crate::theme::STROKE_PX
|
||||
},
|
||||
radius: ui.radius(box_.shape, height),
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! 设置页「General → 主题」行:Ink / Silver / 跟随系统三档(M229)。
|
||||
//!
|
||||
//! 从 `general.rs` 拆出来的理由只有一条:加这三档之后那个文件 474 行(400 行硬线)。
|
||||
//! 拆法是「整段搬走 + 一个 `pub(super) fn row` 入口」,排版与 handler 一字未改。
|
||||
//!
|
||||
//! 三档的语义:**Ink = 深色主方案、Silver = 浅色主方案**,落盘取值仍是 `appearance.theme_mode`
|
||||
//! 的 `dark` / `light`;第三档「跟随系统」= `auto`,由系统深浅落到对应色板
|
||||
//! (`BrandBoard::for_flavor`)。所以这里**不新增配置键**,旧配置读进来语义也一致。
|
||||
|
||||
use iced::widget::{button, column, container, row};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme};
|
||||
|
||||
use unlucky_design::color::{Role, StateLayer};
|
||||
use unlucky_design::inkcat::metrics as chipbox;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::catalog::{Catalog, Key};
|
||||
use unlucky_presenter::prefs::ThemeMode;
|
||||
|
||||
use crate::pages::{frame, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 三档全序(Ink / Silver / 跟随系统):`ThemeMode` 在 presenter,声明序是 `Auto/Light/Dark`,
|
||||
/// 画出来的顺序按「深 → 浅 → 跟随」,所以这里另给一张表,不靠枚举声明序。
|
||||
pub(super) const THEME_MODES: [ThemeMode; 3] = [ThemeMode::Dark, ThemeMode::Light, ThemeMode::Auto];
|
||||
|
||||
/// 主题行:标题 + 三档互斥 chip + 一句说明(排版与语言行同族)。
|
||||
pub(super) fn view<'a>(ui: &'a Ui, catalog: &Catalog, mode: ThemeMode) -> Element<'a, Message> {
|
||||
let head = row![
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
catalog.text(Key::SettingsTheme),
|
||||
ui.color(Role::OnSurface),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelLarge,
|
||||
true,
|
||||
catalog.text(mode_label(mode)),
|
||||
ui.color(Role::OnPrimaryFixedVariant),
|
||||
),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let chips = row(THEME_MODES
|
||||
.iter()
|
||||
.map(|choice| {
|
||||
chip(
|
||||
ui,
|
||||
catalog.text(mode_label(*choice)),
|
||||
*choice == mode,
|
||||
*choice,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
column![
|
||||
head,
|
||||
chips,
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::SettingsThemeHint),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一档主题的选项名(Ink / Silver / 跟随系统)。
|
||||
fn mode_label(mode: ThemeMode) -> Key {
|
||||
match mode {
|
||||
ThemeMode::Dark => Key::SettingsThemeInk,
|
||||
ThemeMode::Light => Key::SettingsThemeSilver,
|
||||
ThemeMode::Auto => Key::SettingsThemeFollowSystem,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一枚主题档位按钮(选中态 `SecondaryContainer`,未选中透明 + 1px `OutlineVariant`)。
|
||||
///
|
||||
/// 几何取规格卡的 chip(高 32、圆角 8,`inkcat::metrics::CHIP`):主题三档是**可并排的筛选**,
|
||||
/// 不是主行动按钮,用 chip 档比语言行那枚 40 高药丸更贴规格卡。
|
||||
fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, mode: ThemeMode) -> Element<'a, Message> {
|
||||
let box_ = chipbox::CHIP;
|
||||
let height = box_.height;
|
||||
let content_color = if selected {
|
||||
ui.color(Role::OnSecondaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
let border_color = ui.color(Role::OutlineVariant);
|
||||
let stroke = crate::theme::STROKE_PX;
|
||||
|
||||
button(
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelLarge,
|
||||
selected,
|
||||
text,
|
||||
content_color,
|
||||
))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, chipbox::CHIP_PAD_X]))
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = if selected {
|
||||
Some(Background::Color(ui.color(Role::SecondaryContainer)))
|
||||
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
Some(Background::Color(ui.state_over(
|
||||
StateLayer::Hover,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background,
|
||||
text_color: content_color,
|
||||
border: Border {
|
||||
color: border_color,
|
||||
width: if selected { 0.0 } else { stroke },
|
||||
radius: ui.radius(box_.shape, height),
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
})
|
||||
.on_press(Message::Theme(mode))
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 三档 = 两块板 + 一种跟随,顺序是深 → 浅 → 跟随(换序会让「Ink 在最左」这条观感失守)。
|
||||
#[test]
|
||||
fn the_three_modes_are_ink_silver_and_follow_system() {
|
||||
assert_eq!(
|
||||
THEME_MODES,
|
||||
[ThemeMode::Dark, ThemeMode::Light, ThemeMode::Auto]
|
||||
);
|
||||
assert_eq!(mode_label(ThemeMode::Dark), Key::SettingsThemeInk);
|
||||
assert_eq!(mode_label(ThemeMode::Light), Key::SettingsThemeSilver);
|
||||
assert_eq!(mode_label(ThemeMode::Auto), Key::SettingsThemeFollowSystem);
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,16 @@
|
||||
|
||||
/// 语言 + 外观线的状态(**只归本线**)。
|
||||
///
|
||||
/// 首版是空结构:这一条线还没有自己的运行期状态(外观那份状态是 `pages::appearance::State`,
|
||||
/// 归 M93,走 `States::appearance` 那条既有通道,不在这里)。加字段只改本文件。
|
||||
/// 外观那份运行期状态是 `pages::appearance::State`(M93),走 `States::appearance` 那条既有通道,
|
||||
/// 不在这里。本结构只装**分组自己的**档位:M229 起主题三档(Ink / Silver / 跟随系统)住这儿,
|
||||
/// 由 `app` 在启动与切换时同步(唯一写者见 `app/state/theme.rs::set_theme_mode`)。
|
||||
///
|
||||
/// 初值 `Auto`:harness / 演示路径没有配置可读,跟随系统正是出厂值。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct State {}
|
||||
pub struct State {
|
||||
/// 当前主题档(Ink = `Dark` / Silver = `Light` / 跟随系统 = `Auto`)。
|
||||
pub theme: unlucky_presenter::prefs::ThemeMode,
|
||||
}
|
||||
|
||||
/// 语言 + 外观线的消息(**只归本线**)。
|
||||
///
|
||||
|
||||
@@ -358,7 +358,7 @@ fn section_columns<'a>(
|
||||
// 档位与生效语言不在 [`States`] 里,只能从 `pages::Language` 进来;它与 `appearance` 在不在场
|
||||
// 无关(harness 那条路径也画得出真控件)。
|
||||
let card = if index == GENERAL_GROUP {
|
||||
general::body(ui, language)
|
||||
general::body(ui, states, language)
|
||||
} else if index == UPDATE_GROUP {
|
||||
// M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。
|
||||
update::body(ui, update, language.effective)
|
||||
|
||||
@@ -17,20 +17,19 @@ use unlucky_design::icons::IconKind;
|
||||
use unlucky_i18n::LangChoice;
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
|
||||
/// 这一组的**静态演示行**:语言那一行已经换真控件,所以只剩三行。
|
||||
/// 这一组的**静态演示行**:语言(M106)与主题(M229)都已经换成真控件,所以只剩两行。
|
||||
///
|
||||
/// 抓得住:①四条线并行合并时把别人的行粘到本文件;②语言行被「顺手」加回静态表——
|
||||
/// 那等于把用户看到的假值又写回去(它的值必须跟着语言变,静态表做不到)。
|
||||
/// 抓不住:真控件画出来长什么样(那是截图验收的事,见 `DevDocs/artifacts/m106/`)。
|
||||
/// 抓得住:①四条线并行合并时把别人的行粘到本文件;②语言/主题被「顺手」加回静态表——
|
||||
/// 那等于把用户看到的假值又写回去(它们的值必须跟着语言/档位变,静态表做不到)。
|
||||
/// 抓不住:真控件画出来长什么样(那是截图验收的事,见 `DevDocs/artifacts/m229/`)。
|
||||
#[test]
|
||||
fn the_general_group_keeps_theme_startup_and_close_as_static_rows() {
|
||||
fn the_general_group_keeps_only_the_two_rows_without_a_backend() {
|
||||
let rows: Vec<(&str, &str)> = general::GROUP.rows.to_vec();
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![
|
||||
("Theme", "Follow system"),
|
||||
("Startup page", "Instances"),
|
||||
("Close button", "Exit to tray"),
|
||||
("Close button", "Exit to tray")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! | 标题栏高度 | 16.2 桌面标题栏 **40**(登记偏离) | `LM.titlebar_height = 48`(M63 §1.4/R13 桌面档,令牌优先) |
|
||||
//! | 标题栏按钮 | 16.5 官方图标 **24** → 本项目 **20**(M86 项目偏离,用户点名「图标稍大」) | `icons::CAPTION_PX`(令牌 `TITLEBAR_CAPTION_ICON_PX`)+ `LM.titlebar_button_width = 46` |
|
||||
//! | 页头 | 16.1 官方 AppBar **64**;本项目 **56** | `LM.page_header_height = 56`(已有偏离登记) |
|
||||
//! | 卡片 | 3.x Outlined:**Medium(12)**、L0、`OutlineVariant` 1px、`SurfaceContainerLow` | `Component::OutlinedCard.spec()` + `STROKE_PX` |
|
||||
//! | 卡片 | 3.x Outlined,圆角取**规格卡 16**(`Large`;组件表 `OutlinedCard` 的官方值是 12)、`OutlineVariant` 1px、卡片层底 | `Ui::card_shape()` + `STROKE_PX` |
|
||||
//! | 字级 | 16.4 标题 `TitleLarge`;卡片 `TitleMedium` / `BodySmall` | `Ui::style` / `Ui::emphasized` |
|
||||
//!
|
||||
//! 导航轨在 [`nav`] 子模块里(12b §5 的判据表也在那边)。
|
||||
|
||||
+70
-154
@@ -1,8 +1,20 @@
|
||||
//! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。
|
||||
//! # 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。
|
||||
//!
|
||||
//! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方(M61 §0):组件层只调
|
||||
//! [`Ui`],绝不出现色值/字号/半径/间距字面量(`tests/no_hardcoded_tokens.rs` 扫 `src/**`)。
|
||||
//!
|
||||
//! ## 两套来源:品牌色板(出厂)与取色方案(D38)
|
||||
//!
|
||||
//! M229 起 [`Ui::new`] 走 **品牌色板**([`BrandBoard`]:Ink = 深色主方案 / Silver = 浅色主方案),
|
||||
//! `Scheme::baseline`(tone 派生)退回它本来的角色——D38 取色链的中性锚点。两条路径并列:
|
||||
//!
|
||||
//! - [`Ui::brand`]:出厂外观,色板自己决定深浅([`BrandBoard::flavor`]);
|
||||
//! - [`Ui::from_scheme`]:背景图 / 壁纸取色成功时接管 49 个角色(取色失败回落色板);
|
||||
//! - [`Ui::baseline`]:老的 tone 派生口径,只给对照与测试用(产品路径不再默认走它)。
|
||||
//!
|
||||
//! 「跟随系统」是**设置层的第三种态**,不是第三套板:系统浅色 → Silver、系统深色 → Ink
|
||||
//! ([`BrandBoard::for_flavor`])。色板始终只有两套。
|
||||
//!
|
||||
//! ## 为什么不用 `iced::theme::Base` 承载 49 个角色
|
||||
//!
|
||||
//! iced 的 `Palette` 只有 6 色,M3 有 49 个角色(M60 Q5):真源是本模块的 [`Scheme`],`Theme`
|
||||
@@ -13,8 +25,9 @@ use iced::border::Radius;
|
||||
use iced::theme::Palette;
|
||||
use iced::{Border, Color, Font, Theme};
|
||||
|
||||
use unlucky_design::color::{Role, Scheme, SchemeFlavor, StateLayer};
|
||||
use unlucky_design::color::{BrandBoard, Role, Scheme, SchemeFlavor, StateLayer};
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::inkcat::metrics::{self as inkcat, SpecBox};
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||||
use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight};
|
||||
@@ -40,6 +53,8 @@ pub struct Families {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ui {
|
||||
scheme: Scheme,
|
||||
/// 当前色板(Ink / Silver)。`from_scheme` 时取方案深浅对应的那块,`name()` 与图例用它。
|
||||
board: BrandBoard,
|
||||
platform: Platform,
|
||||
metrics: LayoutMetrics,
|
||||
families: Families,
|
||||
@@ -48,15 +63,28 @@ pub struct Ui {
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
/// 按平台(由 `cfg!` 决定)与令牌基线方案求值。
|
||||
/// 按平台(由 `cfg!` 决定)与**品牌色板**求值:深浅决定色板(浅 → Silver / 深 → Ink)。
|
||||
///
|
||||
/// 这是产品出厂外观的唯一入口(M229):`DesignTokens::brand(BrandBoard::for_flavor(flavor))`。
|
||||
pub fn new(flavor: SchemeFlavor, families: Families) -> Self {
|
||||
Self::with_platform(flavor, Platform::current(), families)
|
||||
}
|
||||
|
||||
/// 显式指定平台(跨平台核对与测试用)。
|
||||
pub fn with_platform(flavor: SchemeFlavor, platform: Platform, families: Families) -> Self {
|
||||
Self::with_board(BrandBoard::for_flavor(flavor), platform, families)
|
||||
}
|
||||
|
||||
/// 按**指定色板**求值(色板自己决定深浅,见 [`BrandBoard::flavor`])。
|
||||
pub fn brand(board: BrandBoard, families: Families) -> Self {
|
||||
Self::with_board(board, Platform::current(), families)
|
||||
}
|
||||
|
||||
/// 指定色板 + 平台(截图与测试用)。
|
||||
pub fn with_board(board: BrandBoard, platform: Platform, families: Families) -> Self {
|
||||
Self {
|
||||
scheme: Scheme::baseline(flavor),
|
||||
scheme: board.scheme(),
|
||||
board,
|
||||
platform,
|
||||
metrics: LayoutMetrics::DESKTOP,
|
||||
families,
|
||||
@@ -64,17 +92,30 @@ impl Ui {
|
||||
}
|
||||
}
|
||||
|
||||
/// tone 派生的 `Scheme::baseline` 口径(D38 取色链的中性锚点;产品路径不再默认走它)。
|
||||
///
|
||||
/// 留成公开入口是为了对照与测试:`Ui::new(flavor)` 与它在**颜色上不同**,那是 M229 的换肤。
|
||||
pub fn baseline(flavor: SchemeFlavor, families: Families) -> Self {
|
||||
Self::from_scheme(Scheme::baseline(flavor), families)
|
||||
}
|
||||
|
||||
/// 当前深浅。
|
||||
pub fn flavor(&self) -> SchemeFlavor {
|
||||
self.scheme.flavor()
|
||||
}
|
||||
|
||||
/// 用**已解析的方案**构造求值器(D38:背景图 / 壁纸取色 → 49 个角色换掉 baseline)。
|
||||
/// 当前色板(Ink / Silver)。
|
||||
pub fn board(&self) -> BrandBoard {
|
||||
self.board
|
||||
}
|
||||
|
||||
/// 用**已解析的方案**构造求值器(D38:背景图 / 壁纸取色 → 49 个角色换掉色板)。
|
||||
///
|
||||
/// 追加式入口:`new` / `with_platform` 的行为一字未动(默认仍是 [`Scheme::baseline`]);
|
||||
/// 平台与骨架尺寸仍按当前环境求值——方案只带颜色。
|
||||
/// 色板字段取方案深浅对应的那块(`Light → Silver` / `Dark → Ink`):取色方案只换颜色,
|
||||
/// 不改变「这一屏是深色还是浅色」,所以 `name()` 仍然说实话。
|
||||
pub fn from_scheme(scheme: Scheme, families: Families) -> Self {
|
||||
Self {
|
||||
board: BrandBoard::for_flavor(scheme.flavor()),
|
||||
scheme,
|
||||
platform: Platform::current(),
|
||||
metrics: LayoutMetrics::DESKTOP,
|
||||
@@ -150,6 +191,20 @@ impl Ui {
|
||||
Radius::new(shape.radius_px(side))
|
||||
}
|
||||
|
||||
/// **规格卡口径的卡片方框**(`ELEVATED CARD` / `CARD`,`inkcat::metrics::CARD`)。
|
||||
///
|
||||
/// 与 `component_map` 的 `OutlinedCard = Medium(12)` 差 4:规格卡画的是 `rx=16`(Large),
|
||||
/// M228 的 `inkcat` 模块把这条差异登记为「换肤时取 `CARD`」,M229 就是那次换肤。表值不动
|
||||
/// (它还服务别的面),页面卡片统一从这里取。
|
||||
pub fn card_box(&self) -> SpecBox {
|
||||
inkcat::CARD
|
||||
}
|
||||
|
||||
/// 卡片圆角档(= [`Self::card_box`] 的 `shape`,即 `ShapeScale::Large` = 16)。
|
||||
pub fn card_shape(&self) -> ShapeScale {
|
||||
self.card_box().shape
|
||||
}
|
||||
|
||||
/// 固定档圆角。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。
|
||||
pub fn radius_dp(&self, shape: ShapeScale) -> Radius {
|
||||
debug_assert!(
|
||||
@@ -225,10 +280,14 @@ impl Ui {
|
||||
}
|
||||
|
||||
/// `Theme::name()` 的取值(稳定字符串,改名要先在协作文档登记)。
|
||||
///
|
||||
/// M229 起按**色板**取名(`ull-ink` / `ull-silver`):换肤之后「深色」这个词不再等于
|
||||
/// 「M3 深色 tone 派生」,名字必须能区分「是哪块板」。D38 取色方案仍叫它所属深浅的那块
|
||||
/// (取色只换颜色,不换深浅)。
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self.flavor() {
|
||||
SchemeFlavor::Light => "ull-m3-light",
|
||||
SchemeFlavor::Dark => "ull-m3-dark",
|
||||
match self.board {
|
||||
BrandBoard::Ink => "ull-ink",
|
||||
BrandBoard::Silver => "ull-silver",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,147 +313,4 @@ pub fn weight_of(weight: Weight) -> iced::font::Weight {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI Variable Text",
|
||||
display: "Segoe UI Variable Display",
|
||||
mono: "Cascadia Mono",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_name_tracks_the_flavor() {
|
||||
assert_eq!(ui(SchemeFlavor::Light).name(), "ull-m3-light");
|
||||
assert_eq!(ui(SchemeFlavor::Dark).name(), "ull-m3-dark");
|
||||
assert_ne!(
|
||||
ui(SchemeFlavor::Light).theme().to_string(),
|
||||
ui(SchemeFlavor::Dark).theme().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_card_overlay_follow_surface_containers() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
assert_eq!(ui.layer(MaterialLayer::Canvas), ui.color(Role::Surface));
|
||||
assert_eq!(
|
||||
ui.layer(MaterialLayer::Card),
|
||||
ui.color(Role::SurfaceContainerLow)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.layer(MaterialLayer::Overlay),
|
||||
ui.color(Role::SurfaceContainerHigh)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radii_only_come_from_the_scale() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
// 卡片 Medium(12):边长够大时就是 12
|
||||
assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0);
|
||||
assert_eq!(ui.radius_dp(ShapeScale::ExtraSmall).top_left, 4.0);
|
||||
// 药丸 Full:按边长一半折算
|
||||
assert_eq!(ui.radius(ShapeScale::Full, 32.0).top_left, 16.0);
|
||||
// 不许超过边长一半
|
||||
assert_eq!(ui.radius(ShapeScale::ExtraLarge, 20.0).top_left, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlines_are_one_pixel_and_use_outline_variant() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let border = ui.outlined(ShapeScale::Medium);
|
||||
assert_eq!(border.width, STROKE_PX);
|
||||
assert_eq!(border.width, 1.0, "rubric G7:全站 1px");
|
||||
assert_eq!(border.color, ui.color(Role::OutlineVariant));
|
||||
assert_eq!(border.radius.top_left, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasized_weights_are_the_official_ones() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
// title-small 的强调档是 700(官方 Emphasized)
|
||||
assert_eq!(
|
||||
ui.emphasized_font(TypeRole::TitleSmall).weight,
|
||||
iced::font::Weight::Bold
|
||||
);
|
||||
// baseline 正文是 400
|
||||
assert_eq!(
|
||||
ui.font(TypeRole::BodyMedium).weight,
|
||||
iced::font::Weight::Normal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_text_keeps_wcag_contrast_in_both_flavors() {
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let scheme = Scheme::baseline(flavor);
|
||||
assert!(
|
||||
scheme.contrast(Role::OnSurface, Role::Surface) >= 4.5,
|
||||
"{flavor:?} 的正文对比度不够"
|
||||
);
|
||||
assert!(
|
||||
scheme.contrast(Role::OnSecondaryContainer, Role::SecondaryContainer) >= 4.5,
|
||||
"{flavor:?} 的选中态药丸对比度不够"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_role_maps_to_an_iced_color() {
|
||||
for role in Role::ALL {
|
||||
let color = ui(SchemeFlavor::Dark).color(role);
|
||||
assert!((0.0..=1.0).contains(&color.a), "{role:?} 的 alpha 越界");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_layers_are_opaque_so_cpu_and_gpu_match() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let hovered = ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface);
|
||||
assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果");
|
||||
assert_ne!(hovered, ui.color(Role::Surface));
|
||||
}
|
||||
|
||||
/// D38:配色方案必须真的换色;默认构造逐字段等于 baseline(防回流的那一半)。
|
||||
/// 抓不住:取色好不好看、设置页是否真切过去(后者由 app 侧日志举证)。
|
||||
#[test]
|
||||
fn a_resolved_scheme_replaces_the_baseline_colours() {
|
||||
use unlucky_design::palette::{resolve_source, CardKey, ImageInput};
|
||||
|
||||
// 4×4 的纯红图:种子彩度远高于门槛,必然走到「背景图取色」这一级。
|
||||
let pixels = [0xC0_u8, 0x20, 0x20, 0xFF].repeat(16);
|
||||
let input = ImageInput::pixels(&pixels, 4, 4);
|
||||
let (scheme, provenance) =
|
||||
resolve_source(Some(input), None, Some(CardKey::Sky), SchemeFlavor::Light);
|
||||
assert_eq!(
|
||||
provenance.level,
|
||||
unlucky_design::palette::Level::BackgroundImage,
|
||||
"纯红图必须取色成功(否则本测试的输入不合格)"
|
||||
);
|
||||
|
||||
let tinted = Ui::from_scheme(scheme, FAMILIES);
|
||||
let baseline = ui(SchemeFlavor::Light);
|
||||
assert_ne!(
|
||||
tinted.color(Role::Primary),
|
||||
baseline.color(Role::Primary),
|
||||
"取色方案必须换掉 primary"
|
||||
);
|
||||
// 深浅仍跟着方案走(方案自带 flavor),不会把 Light 变成 Dark。
|
||||
assert_eq!(tinted.flavor(), SchemeFlavor::Light);
|
||||
assert_eq!(tinted.name(), baseline.name());
|
||||
|
||||
// 默认构造不变:`with_platform` 与 baseline 逐块相等。
|
||||
let explicit = Ui::from_scheme(Scheme::baseline(SchemeFlavor::Light), FAMILIES);
|
||||
for role in Role::ALL {
|
||||
assert_eq!(
|
||||
explicit.color(role),
|
||||
baseline.color(role),
|
||||
"{role:?}:不传 scheme 时默认行为必须与改动前一致"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,207 @@
|
||||
//! `theme` 的单元测试(M229 从 `theme.rs` 纯搬移,守 400 行硬线:加双色板字段前该文件正好 400 行)。
|
||||
//!
|
||||
//! 内容一行未改,只把 `mod tests { … }` 的包裹层换成独立文件。
|
||||
|
||||
use super::*;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI Variable Text",
|
||||
display: "Segoe UI Variable Display",
|
||||
mono: "Cascadia Mono",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_name_tracks_the_flavor() {
|
||||
assert_eq!(ui(SchemeFlavor::Light).name(), "ull-silver");
|
||||
assert_eq!(ui(SchemeFlavor::Dark).name(), "ull-ink");
|
||||
assert_ne!(
|
||||
ui(SchemeFlavor::Light).theme().to_string(),
|
||||
ui(SchemeFlavor::Dark).theme().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_card_overlay_follow_surface_containers() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
assert_eq!(ui.layer(MaterialLayer::Canvas), ui.color(Role::Surface));
|
||||
assert_eq!(
|
||||
ui.layer(MaterialLayer::Card),
|
||||
ui.color(Role::SurfaceContainerLow)
|
||||
);
|
||||
assert_eq!(
|
||||
ui.layer(MaterialLayer::Overlay),
|
||||
ui.color(Role::SurfaceContainerHigh)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radii_only_come_from_the_scale() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
// 卡片 Medium(12):边长够大时就是 12
|
||||
assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0);
|
||||
assert_eq!(ui.radius_dp(ShapeScale::ExtraSmall).top_left, 4.0);
|
||||
// 药丸 Full:按边长一半折算
|
||||
assert_eq!(ui.radius(ShapeScale::Full, 32.0).top_left, 16.0);
|
||||
// 不许超过边长一半
|
||||
assert_eq!(ui.radius(ShapeScale::ExtraLarge, 20.0).top_left, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlines_are_one_pixel_and_use_outline_variant() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let border = ui.outlined(ShapeScale::Medium);
|
||||
assert_eq!(border.width, STROKE_PX);
|
||||
assert_eq!(border.width, 1.0, "rubric G7:全站 1px");
|
||||
assert_eq!(border.color, ui.color(Role::OutlineVariant));
|
||||
assert_eq!(border.radius.top_left, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasized_weights_are_the_official_ones() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
// title-small 的强调档是 700(官方 Emphasized)
|
||||
assert_eq!(
|
||||
ui.emphasized_font(TypeRole::TitleSmall).weight,
|
||||
iced::font::Weight::Bold
|
||||
);
|
||||
// baseline 正文是 400
|
||||
assert_eq!(
|
||||
ui.font(TypeRole::BodyMedium).weight,
|
||||
iced::font::Weight::Normal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_text_keeps_wcag_contrast_in_both_flavors() {
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let scheme = Scheme::baseline(flavor);
|
||||
assert!(
|
||||
scheme.contrast(Role::OnSurface, Role::Surface) >= 4.5,
|
||||
"{flavor:?} 的正文对比度不够"
|
||||
);
|
||||
assert!(
|
||||
scheme.contrast(Role::OnSecondaryContainer, Role::SecondaryContainer) >= 4.5,
|
||||
"{flavor:?} 的选中态药丸对比度不够"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_role_maps_to_an_iced_color() {
|
||||
for role in Role::ALL {
|
||||
let color = ui(SchemeFlavor::Dark).color(role);
|
||||
assert!((0.0..=1.0).contains(&color.a), "{role:?} 的 alpha 越界");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_layers_are_opaque_so_cpu_and_gpu_match() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let hovered = ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface);
|
||||
assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果");
|
||||
assert_ne!(hovered, ui.color(Role::Surface));
|
||||
}
|
||||
|
||||
/// D38:配色方案必须真的换色;显式传 baseline 时逐字段等于 baseline。
|
||||
/// 抓不住:取色好不好看、设置页是否真切过去(后者由 app 侧日志举证)。
|
||||
#[test]
|
||||
fn a_resolved_scheme_replaces_the_baseline_colours() {
|
||||
use unlucky_design::palette::{resolve_source, CardKey, ImageInput};
|
||||
|
||||
// 4×4 的纯红图:种子彩度远高于门槛,必然走到「背景图取色」这一级。
|
||||
let pixels = [0xC0_u8, 0x20, 0x20, 0xFF].repeat(16);
|
||||
let input = ImageInput::pixels(&pixels, 4, 4);
|
||||
let (scheme, provenance) =
|
||||
resolve_source(Some(input), None, Some(CardKey::Sky), SchemeFlavor::Light);
|
||||
assert_eq!(
|
||||
provenance.level,
|
||||
unlucky_design::palette::Level::BackgroundImage,
|
||||
"纯红图必须取色成功(否则本测试的输入不合格)"
|
||||
);
|
||||
|
||||
let tinted = Ui::from_scheme(scheme, FAMILIES);
|
||||
let board = ui(SchemeFlavor::Light);
|
||||
assert_ne!(
|
||||
tinted.color(Role::Primary),
|
||||
board.color(Role::Primary),
|
||||
"取色方案必须换掉 primary"
|
||||
);
|
||||
// 深浅仍跟着方案走(方案自带 flavor),不会把 Light 变成 Dark;色板名也仍说实话。
|
||||
assert_eq!(tinted.flavor(), SchemeFlavor::Light);
|
||||
assert_eq!(tinted.name(), board.name());
|
||||
|
||||
// 显式传 baseline 时逐块等于 baseline 口径(M229 之后产品默认不再是它,但这条通路必须诚实)。
|
||||
let explicit = Ui::from_scheme(Scheme::baseline(SchemeFlavor::Light), FAMILIES);
|
||||
let baseline = Ui::baseline(SchemeFlavor::Light, FAMILIES);
|
||||
for role in Role::ALL {
|
||||
assert_eq!(
|
||||
explicit.color(role),
|
||||
baseline.color(role),
|
||||
"{role:?}:显式传 baseline 时颜色必须与 baseline 口径一致"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// M229:`Ui::new` 走**品牌色板**(Ink 深 / Silver 浅),不再走 tone 派生的 `baseline`。
|
||||
///
|
||||
/// 抓得住:换肤没接上(色值仍是 baseline)、深浅映射反了(Light 拿到 Ink)。
|
||||
/// 抓不住:色板好不好看、页面有没有绕过 `Ui::color` 自己写色值(后者由 `no_hardcoded_tokens` 扫)。
|
||||
#[test]
|
||||
fn every_ui_is_built_from_a_brand_board() {
|
||||
for (flavor, board) in [
|
||||
(SchemeFlavor::Light, BrandBoard::Silver),
|
||||
(SchemeFlavor::Dark, BrandBoard::Ink),
|
||||
] {
|
||||
let evaluator = ui(flavor);
|
||||
assert_eq!(evaluator.board(), board, "{flavor:?} 该用 {:?}", board);
|
||||
assert_eq!(evaluator.name(), board_name(board));
|
||||
for role in Role::ALL {
|
||||
assert_eq!(
|
||||
evaluator.color(role),
|
||||
to_iced(board.get(role)),
|
||||
"{flavor:?} 的 {role:?} 不是色板值"
|
||||
);
|
||||
}
|
||||
// 与 tone 派生的 baseline 确实不同(否则「换肤」这件事没发生)。
|
||||
let baseline = Ui::baseline(flavor, FAMILIES);
|
||||
assert_ne!(
|
||||
evaluator.color(Role::Primary),
|
||||
baseline.color(Role::Primary),
|
||||
"{flavor:?}:色板与 baseline 的 primary 撞了,换肤等于没接"
|
||||
);
|
||||
assert_eq!(evaluator.flavor(), flavor);
|
||||
}
|
||||
}
|
||||
|
||||
/// 显式选板时深浅跟着板走(`Ink = Dark` / `Silver = Light`),「跟随系统」不必另造一种板。
|
||||
#[test]
|
||||
fn picking_a_board_sets_the_flavor_from_the_board() {
|
||||
let ink = Ui::with_board(BrandBoard::Ink, Platform::Windows, FAMILIES);
|
||||
let silver = Ui::with_board(BrandBoard::Silver, Platform::Windows, FAMILIES);
|
||||
assert_eq!(ink.flavor(), SchemeFlavor::Dark);
|
||||
assert_eq!(silver.flavor(), SchemeFlavor::Light);
|
||||
assert_ne!(ink.theme().to_string(), silver.theme().to_string());
|
||||
}
|
||||
|
||||
/// 卡片方框走规格卡(`rx=16`),不是组件表的 `Medium(12)`。
|
||||
#[test]
|
||||
fn cards_take_the_spec_corner() {
|
||||
let evaluator = ui(SchemeFlavor::Light);
|
||||
assert_eq!(evaluator.card_shape(), ShapeScale::Large);
|
||||
assert_eq!(evaluator.card_box().radius_px(), 16.0);
|
||||
assert_eq!(
|
||||
evaluator.outlined(evaluator.card_shape()).radius.top_left,
|
||||
16.0
|
||||
);
|
||||
}
|
||||
|
||||
fn board_name(board: BrandBoard) -> &'static str {
|
||||
match board {
|
||||
BrandBoard::Ink => "ull-ink",
|
||||
BrandBoard::Silver => "ull-silver",
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,15 @@ macro_rules! general_entries {
|
||||
SettingsLanguageEffective => ("Interface right now: {language}", "界面当前使用:{language}"),
|
||||
SettingsTheme => ("Theme", "主题"),
|
||||
SettingsThemeFollowSystem => ("Follow system", "跟随系统"),
|
||||
// M229:主题三态改成「Ink / Silver / 跟随系统」——前两档就是两块品牌色板
|
||||
// (Ink = 深色主方案 / Silver = 浅色主方案),落盘仍是 `appearance.theme_mode` 的
|
||||
// `dark` / `light` / `auto` 三个值,不新增配置键。
|
||||
SettingsThemeInk => ("Ink", "Ink(深色)"),
|
||||
SettingsThemeSilver => ("Silver", "Silver(浅色)"),
|
||||
SettingsThemeHint => (
|
||||
"Ink is the dark scheme, Silver the light one. Follow system picks between them as Windows switches.",
|
||||
"Ink 是深色主方案,Silver 是浅色主方案;跟随系统会随 Windows 的深浅在这两块之间切换。"
|
||||
),
|
||||
SettingsStartupPage => ("Startup page", "启动页"),
|
||||
SettingsStartupInstances => ("Instances", "实例"),
|
||||
SettingsCloseButton => ("Close button", "关闭按钮"),
|
||||
|
||||
@@ -186,6 +186,13 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("SettingsLanguageEffective", "Interface right now: {language}", "界面当前使用:{language}"),
|
||||
("SettingsTheme", "Theme", "主题"),
|
||||
("SettingsThemeFollowSystem", "Follow system", "跟随系统"),
|
||||
("SettingsThemeInk", "Ink", "Ink(深色)"),
|
||||
("SettingsThemeSilver", "Silver", "Silver(浅色)"),
|
||||
(
|
||||
"SettingsThemeHint",
|
||||
"Ink is the dark scheme, Silver the light one. Follow system picks between them as Windows switches.",
|
||||
"Ink 是深色主方案,Silver 是浅色主方案;跟随系统会随 Windows 的深浅在这两块之间切换。",
|
||||
),
|
||||
("SettingsStartupPage", "Startup page", "启动页"),
|
||||
("SettingsStartupInstances", "Instances", "实例"),
|
||||
("SettingsCloseButton", "Close button", "关闭按钮"),
|
||||
|
||||
Reference in new issue
Block a user