Compare commits
35
Commits
614bd253c8
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35345f3399 | ||
|
|
4d7b198822 | ||
|
|
4954ba3463 | ||
|
|
b563c6ac44 | ||
|
|
eed1c9d3f7 | ||
|
|
b31a758cda | ||
|
|
a878fc6a58 | ||
|
|
ab5f5802a8 | ||
|
|
95a6f56060 | ||
|
|
a3b3c9eba7 | ||
|
|
86f7a31d9a | ||
|
|
5fd1f3ca26 | ||
|
|
d075feb77d | ||
|
|
e905fa3922 | ||
|
|
8072f1cc7e | ||
|
|
5f85891e7e | ||
|
|
5861881073 | ||
|
|
4774e7b788 | ||
|
|
22ddc74784 | ||
|
|
831bd00620 | ||
|
|
589f51f4fb | ||
|
|
87d5dd8f67 | ||
|
|
0de8e63ad6 | ||
|
|
c5c543ba43 | ||
|
|
ddf6937b2b | ||
|
|
a650f8184b | ||
|
|
dc67be529d | ||
|
|
c495fccd6c | ||
|
|
a0ccf79b2b | ||
|
|
367100848a | ||
|
|
fae1b61d9e | ||
|
|
2c442585b1 | ||
|
|
352dd7937e | ||
|
|
69d0025d09 | ||
|
|
d3e9a62b21 |
No files matched your search
@@ -0,0 +1,37 @@
|
||||
//! 页面骨架截图入口(M84 `PAGEFRAME` 的验收通道)。
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p unlucky-app-iced --example pages_demo -- \
|
||||
//! --screen=instances --width=1180 --height=760 \
|
||||
//! --screenshot=out.png --screenshot-theme=light --software-renderer
|
||||
//! ```
|
||||
//!
|
||||
//! 参数解析与 iced 接线都在 `unlucky_app_iced::pages::harness` 里:**本 example 只是入口**,
|
||||
//! 它也刻意不接 `cli.rs`(那边归 M75)。详细口径见 `pages::harness` 的模块文档。
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use unlucky_app_iced::pages::harness::{self, Request};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let request = match Request::parse(std::env::args().skip(1)) {
|
||||
Ok(request) => request,
|
||||
Err(message) => {
|
||||
eprintln!("[cli] {message}");
|
||||
eprintln!(
|
||||
"用法:pages_demo [--screen=instances|download|settings|logs] [--width <px>] \
|
||||
[--height <px>] [--theme=light|dark] [--screenshot-theme=light|dark] \
|
||||
[--screenshot <png>] [--renderer=auto|gpu|cpu] [--software-renderer]"
|
||||
);
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
match harness::run(request) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(message) => {
|
||||
eprintln!("[run] {message}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
-67
@@ -1,16 +1,8 @@
|
||||
//! Elm 三件套:`State` / `Message` / `update` + `view` 接线。
|
||||
//!
|
||||
//! ## 启动序列(顺序有讲究)
|
||||
//!
|
||||
//! 1. **探测渲染后端** → 写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1);
|
||||
//! 2. **求值字体栈**(D28 前半)→ 一行启动日志;
|
||||
//! 3. `iced::application(..).run()`:窗口 1180×760、无边框(自绘标题栏);
|
||||
//! 4. 首个 `view()` 打「到首帧」耗时;截图模式下第 3 帧 `window::screenshot` 落盘后退出。
|
||||
//!
|
||||
//! ## 为什么截图要等 3 帧
|
||||
//!
|
||||
//! 无边框窗口启动时会先收到一次「显示器工作区尺寸」的 `Resized`,再回到设定尺寸(M60 坑 #5),
|
||||
//! 第一帧可能是旧尺寸;再叠上字体系统首次上屏的排版抖动,留 2 帧余量最稳。
|
||||
//! 启动序列:①探测渲染后端并写 `ICED_BACKEND`(必须在 `run()` 之前,M60 Q1)→ ②求值字体栈
|
||||
//! (D28 前半)→ ③`iced::application(..).run()`(1180×760、无边框自绘标题栏)→ ④首个 `view()`
|
||||
//! 打耗时;截图模式第 3 帧落盘后退出(无边框窗首帧可能是旧尺寸,M60 坑 #5)。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
@@ -20,23 +12,28 @@ use iced::{window, window::Direction, Element, Subscription, Task};
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::cli::{Options, Screen};
|
||||
use crate::fonts;
|
||||
use crate::probe;
|
||||
use crate::shell::{self, NavItem};
|
||||
use crate::shot;
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
/// 验收窗口尺寸(1180×760,100% DPI —— D30 的硬要求)。
|
||||
// 组件库(D39)在本文件声明:`src/lib.rs` 是五条组件线共用的文件,改它必然冲突;
|
||||
// `#[path]` 保证物理路径仍是 `src/components/`(组件目录自己的归属规则不变)。
|
||||
#[path = "components/mod.rs"]
|
||||
mod components;
|
||||
|
||||
/// 验收窗口尺寸(1180×760 @100% DPI —— D30 的硬要求)。
|
||||
pub const WINDOW_SIZE: iced::Size = iced::Size {
|
||||
width: 1180.0,
|
||||
height: 760.0,
|
||||
};
|
||||
|
||||
/// 产品名(自绘标题栏的标题;也是 `'static`,好让 view 借用)。
|
||||
/// 产品名(自绘标题栏的标题;`'static` 好让 view 借用)。
|
||||
pub const APP_TITLE: &str = "Unlucky Launcher";
|
||||
|
||||
/// 窗口最小尺寸(低于这个值骨架会挤坏:88 导航 + 页面留白 + 卡片)。
|
||||
/// 窗口最小尺寸(再小骨架会挤坏:88 导航 + 页面留白 + 卡片)。
|
||||
const MIN_SIZE: iced::Size = iced::Size {
|
||||
width: 880.0,
|
||||
height: 560.0,
|
||||
@@ -58,8 +55,6 @@ pub struct App {
|
||||
nav: NavItem,
|
||||
window: Option<window::Id>,
|
||||
frames: u32,
|
||||
/// 截图模式的「主题已定」闸门:`system::theme()` 是异步的,早拍会拍到错的深浅色。
|
||||
theme_resolved: bool,
|
||||
}
|
||||
|
||||
/// 消息(单一大枚举;页面接进来时按页拆子枚举)。
|
||||
@@ -69,29 +64,27 @@ pub enum Message {
|
||||
Boot,
|
||||
/// `window::latest()` 的结果。
|
||||
WindowReady(Option<window::Id>),
|
||||
/// 系统主题探测结果。
|
||||
/// 系统主题探测结果(只可能在**交互模式**出现,截图模式必有主题)。
|
||||
SystemTheme(iced::theme::Mode),
|
||||
/// 一帧画完(只有截图模式才订阅)。
|
||||
Frame,
|
||||
/// 导航项切换。
|
||||
Select(NavItem),
|
||||
/// 按下标题栏 → 拖窗。
|
||||
/// 按下标题栏 → 拖窗 / 边缘热区 → 缩放窗口。
|
||||
DragWindow,
|
||||
/// 按下边缘热区 → 缩放窗口。
|
||||
ResizeStart(Direction),
|
||||
/// 最小化。
|
||||
/// 最小化 / 最大化还原 / 关闭(三个 caption 按钮)。
|
||||
Minimize,
|
||||
/// 最大化/还原。
|
||||
ToggleMaximize,
|
||||
/// 关闭。
|
||||
Close,
|
||||
/// 截图回来了。
|
||||
Captured(window::Screenshot),
|
||||
/// gallery(组件库 harness)里的演示交互。
|
||||
Demo(components::gallery::DemoMessage),
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
|
||||
let options_theme_given = options.shot_flavor().is_some();
|
||||
Self {
|
||||
ui: Ui::new(flavor, families),
|
||||
families,
|
||||
@@ -99,8 +92,6 @@ impl App {
|
||||
nav: NavItem::Instances,
|
||||
window: None,
|
||||
frames: 0,
|
||||
// 显式给了主题就算已定;否则等 `SystemTheme` 到达
|
||||
theme_resolved: options_theme_given,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +104,8 @@ impl App {
|
||||
Message::Boot => {
|
||||
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
||||
|
||||
// 主题:参数里没指定就探系统(深浅两套都要能出图)
|
||||
if self.options.shot_flavor().is_none() {
|
||||
// 交互模式且没显式指定主题 → 探系统;截图模式永不等(见 cli 模块文档)
|
||||
if self.options.wants_system_theme() {
|
||||
tasks.push(iced::system::theme().map(Message::SystemTheme));
|
||||
}
|
||||
|
||||
@@ -129,9 +120,8 @@ impl App {
|
||||
iced::theme::Mode::Dark => SchemeFlavor::Dark,
|
||||
iced::theme::Mode::None | iced::theme::Mode::Light => SchemeFlavor::Light,
|
||||
};
|
||||
eprintln!("[theme] 系统主题 = {mode:?} -> {}", slug(flavor));
|
||||
eprintln!("[theme] 系统主题 = {mode:?}");
|
||||
self.set_flavor(flavor);
|
||||
self.theme_resolved = true;
|
||||
Task::none()
|
||||
}
|
||||
Message::Frame => self.on_frame(),
|
||||
@@ -157,6 +147,11 @@ impl App {
|
||||
},
|
||||
Message::Close => iced::exit(),
|
||||
Message::Captured(screenshot) => self.on_captured(&screenshot),
|
||||
Message::Demo(message) => {
|
||||
// gallery 只是 harness:按下只打日志,不接业务(组件库要能脱离业务验收)
|
||||
eprintln!("[gallery] {} pressed", message.id());
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,14 +169,14 @@ impl App {
|
||||
);
|
||||
self.ui = next;
|
||||
}
|
||||
|
||||
fn on_frame(&mut self) -> Task<Message> {
|
||||
self.frames += 1;
|
||||
|
||||
// 主题没定就不出图:等 `SystemTheme`(否则暗色系统上会拍到浅色图)
|
||||
if !self.has_screenshot() || !self.theme_resolved || self.frames < CAPTURE_AT_FRAME {
|
||||
// 主题在启动时就已确定(截图模式必有主题,见 cli 模块文档),这里只剩帧数闸门
|
||||
if !self.has_screenshot() || self.frames < CAPTURE_AT_FRAME {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match self.window {
|
||||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||||
None => Task::none(),
|
||||
@@ -192,14 +187,14 @@ impl App {
|
||||
let Some(path) = self.options.screenshot.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
match shot::save(screenshot, &path) {
|
||||
Ok(summary) => {
|
||||
eprintln!("{}", summary.describe());
|
||||
eprintln!(
|
||||
"[shot] theme={} renderer={}",
|
||||
"[shot] screen={} theme={} renderer={}",
|
||||
self.options.screen.name(),
|
||||
shot::flavor_slug(&self.ui),
|
||||
renderer_note(self.options.backend)
|
||||
self.options.backend.note()
|
||||
);
|
||||
}
|
||||
Err(error) => eprintln!("[shot] 失败:{error}"),
|
||||
@@ -208,8 +203,8 @@ impl App {
|
||||
iced::exit()
|
||||
}
|
||||
|
||||
/// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
// 只有截图模式才订阅帧事件:否则「每帧一条消息 → 每帧重绘」会白烧 CPU。
|
||||
if self.wants_frames() {
|
||||
window::frames().map(|_| Message::Frame)
|
||||
} else {
|
||||
@@ -240,6 +235,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
|
||||
// 这里只做一次 map,组件库与业务 Message 依然解耦。
|
||||
if self.options.screen == Screen::Gallery {
|
||||
return components::gallery::view(&self.ui).map(Message::Demo);
|
||||
}
|
||||
|
||||
shell::view(
|
||||
&self.ui,
|
||||
self.nav,
|
||||
@@ -252,27 +253,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn renderer_note(backend: crate::Backend) -> &'static str {
|
||||
if cfg!(feature = "gpu") {
|
||||
match backend {
|
||||
crate::Backend::Cpu => "cpu(forced)",
|
||||
crate::Backend::Gpu => "gpu(preferred)",
|
||||
crate::Backend::Auto => "auto",
|
||||
}
|
||||
} else {
|
||||
"cpu(build without wgpu)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 主题名(日志用,和 `Ui::name()` 同一口径)。
|
||||
fn slug(flavor: SchemeFlavor) -> &'static str {
|
||||
if flavor.is_dark() {
|
||||
"ull-m3-dark"
|
||||
} else {
|
||||
"ull-m3-light"
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
|
||||
pub(crate) fn run(options: Options) -> iced::Result {
|
||||
let _ = STARTED.set(Instant::now());
|
||||
@@ -313,8 +293,12 @@ pub(crate) fn run(options: Options) -> iced::Result {
|
||||
}
|
||||
let families = resolved.families();
|
||||
|
||||
// 截图模式:`--screenshot-theme` > `--theme` > 浅色(后续系统探测会覆盖)
|
||||
let flavor = options.shot_flavor().unwrap_or(SchemeFlavor::Light);
|
||||
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口在 `Options::capture_flavor`)。
|
||||
// 这里**不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
|
||||
if options.screenshot.is_some() && options.shot_flavor().is_none() {
|
||||
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
|
||||
}
|
||||
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
|
||||
let boot_options = options.clone();
|
||||
|
||||
let application = iced::application(
|
||||
@@ -374,16 +358,32 @@ mod tests {
|
||||
#[test]
|
||||
fn window_size_is_the_acceptance_size() {
|
||||
assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0));
|
||||
const {
|
||||
assert!(MIN_SIZE.width < WINDOW_SIZE.width);
|
||||
assert!(MIN_SIZE.height < WINDOW_SIZE.height);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_matches_the_theme_name() {
|
||||
assert_eq!(slug(SchemeFlavor::Light), "ull-m3-light");
|
||||
assert_eq!(slug(SchemeFlavor::Dark), "ull-m3-dark");
|
||||
fn screenshot_mode_never_waits_for_the_system_theme() {
|
||||
// 门禁依赖:CI 的 runtime-smoke 不带主题,xvfb 里也没有主题会话。
|
||||
let options = Options {
|
||||
screenshot: Some(std::path::PathBuf::from("x.png")),
|
||||
..Options::default()
|
||||
};
|
||||
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
|
||||
assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light));
|
||||
|
||||
let app = App::new(options, FAMILIES, SchemeFlavor::Light);
|
||||
assert!(app.has_screenshot() && app.ui.name() == "ull-m3-light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gallery_screen_builds_a_tree() {
|
||||
assert_eq!(Options::default().screen, Screen::Shell, "默认仍是产品骨架");
|
||||
let options = Options {
|
||||
screen: Screen::Gallery,
|
||||
..Options::default()
|
||||
};
|
||||
let app = App::new(options, FAMILIES, SchemeFlavor::Light);
|
||||
// 真实像素由 `--screen=gallery --screenshot` 出图 + `gallery::probe` 量
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+162
-66
@@ -1,7 +1,24 @@
|
||||
//! 命令行参数(只做骨架期需要的那些;业务开关等页面 mission 再加)。
|
||||
//! 命令行参数(骨架期 + 组件库 harness 需要的那些;业务开关等页面 mission 再加)。
|
||||
//!
|
||||
//! 取值语法与现役 Slint 版一致:`--key value` 与 `--key=value` 两种都认
|
||||
//! (`crates/app/src/screenshot.rs` 的 `value_of` 是同一口径),这样将来 CI 换 bin 不用改脚本。
|
||||
//!
|
||||
//! ## 参数总表
|
||||
//!
|
||||
//! | 参数 | 取值 | 语义 |
|
||||
//! |---|---|---|
|
||||
//! | `--software-renderer` | — | 等价 `--renderer=cpu`(D27 的 CPU 档) |
|
||||
//! | `--renderer` | `auto`/`gpu`/`cpu` | 渲染后端请求 |
|
||||
//! | `--theme` | `light`/`dark` | 主题;不给则跟随系统(仅交互模式) |
|
||||
//! | `--screenshot` / `--screenshot-theme` | 路径 / `light`·`dark` | 截图通道(出图后退出)与截图专用主题 |
|
||||
//! | `--screen` | `shell`/`gallery` | 启动画面;**`gallery` = 组件库视觉验收 harness**(D39 §5) |
|
||||
//! | `--scale-factor` | `>0` | 强制缩放因子(验收要 100%,即 `1`) |
|
||||
//! | `--diff` / `--diff-scan` | 两个路径 / 非负整数 | 逐像素对比(不开窗)与 ±n px 平移扫描 |
|
||||
//! | `--force-state` | `hover`/`pressed` | 把整屏可交互控件强制成某态(取证) |
|
||||
//! | `--data-dir` | 路径 | 翻牌兼容:**接受并忽略**(CI 冒烟在用) |
|
||||
//!
|
||||
//! **截图主题默认 Light(门禁依赖,不许改回 None)**,理由与不变量见
|
||||
//! [`Options::capture_flavor`] 与 `app.rs` 的截图路径。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -13,7 +30,7 @@ pub enum Backend {
|
||||
/// 探测后决定:有硬件 GPU 就让 iced 自己走 wgpu→tiny-skia 回退;没有就强制 CPU 起步。
|
||||
#[default]
|
||||
Auto,
|
||||
/// 优先 GPU(写 `ICED_BACKEND=wgpu,tiny-skia`:拿不到适配器仍会降级,不硬失败)。
|
||||
/// 优先 GPU(写 `ICED_BACKEND=wgpu,tiny-skia`;拿不到适配器仍会降级,不硬失败)。
|
||||
Gpu,
|
||||
/// 强制 CPU(`--software-renderer` 的等价物,写 `ICED_BACKEND=tiny-skia`)。
|
||||
Cpu,
|
||||
@@ -28,10 +45,42 @@ impl Backend {
|
||||
Backend::Cpu => "cpu",
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图日志用的后端说明(`cfg!(feature = "gpu")` 决定有没有 wgpu 这一档)。
|
||||
pub fn note(self) -> &'static str {
|
||||
if !cfg!(feature = "gpu") {
|
||||
return "cpu(build without wgpu)";
|
||||
}
|
||||
match self {
|
||||
Backend::Cpu => "cpu(forced)",
|
||||
Backend::Gpu => "gpu(preferred)",
|
||||
Backend::Auto => "auto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动画面(D39 §5:`gallery` 是视觉验收 harness,不是产品页面)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Screen {
|
||||
/// 产品骨架:自绘标题栏 + 导航轨 + 内容区。
|
||||
#[default]
|
||||
Shell,
|
||||
/// 组件库 harness:整窗只放状态矩阵。
|
||||
Gallery,
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
/// 稳定名字(日志用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Screen::Shell => "shell",
|
||||
Screen::Gallery => "gallery",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析后的启动参数。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Options {
|
||||
/// 渲染后端请求。
|
||||
pub backend: Backend,
|
||||
@@ -39,24 +88,26 @@ pub struct Options {
|
||||
pub theme: Option<SchemeFlavor>,
|
||||
/// 截图通道:给了路径就出图后退出。
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// 截图时用的主题(`--screenshot-theme`,优先级高于 `--theme`)。
|
||||
/// `--screenshot-theme`(优先级高于 `--theme`)。
|
||||
pub screenshot_theme: Option<SchemeFlavor>,
|
||||
/// 强制窗口缩放因子(截图验收要 100% DPI;`None` = 用系统 DPI)。
|
||||
/// 强制窗口缩放因子(验收要 100% DPI,即 `1`;`None` = 用系统 DPI)。
|
||||
pub scale_factor: Option<f32>,
|
||||
/// `--diff <left> <right>`:只做两图逐像素对比,不开窗。
|
||||
/// `--diff <left> <right>` 与 `--diff-scan <n>`:逐像素对比(不开窗)与 ±n px 平移扫描。
|
||||
pub diff: Option<(PathBuf, PathBuf)>,
|
||||
/// `--diff-scan <n>`:对比时额外做 ±n px 的平移扫描(找最佳对齐,默认 0 = 不扫)。
|
||||
/// 见上(默认 0 = 不扫)。
|
||||
pub diff_scan: u32,
|
||||
/// `--force-state=hover|pressed`:把按钮强制成某个状态便于截图取证(D30 的状态图)。
|
||||
pub force_state: Option<ForcedState>,
|
||||
/// `--data-dir <dir>`:当前**接受并忽略**。
|
||||
///
|
||||
/// 现役 Slint 版的 CI 冒烟用 `--software-renderer --screenshot <png> --data-dir <dir>`,
|
||||
/// 翻牌后同一套脚本要能直接跑 iced 版;真正的数据目录消费由后续页面 mission 接 `host`/`state` 时做。
|
||||
/// `--screen=shell|gallery`:启动画面(`gallery` = 组件库视觉验收 harness)。
|
||||
pub screen: Screen,
|
||||
/// `--data-dir <dir>`:当前**接受并忽略**(现役 Slint 版 CI 冒烟在用;
|
||||
/// 真正的消费由后续页面 mission 接 `host`/`state` 时做)。
|
||||
pub data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 截图取证用的强制交互状态(只影响观感,不改变任何状态机)。
|
||||
///
|
||||
/// 五态完整矩阵由 `--screen=gallery` 的 `Button::state(..)` 出,本开关只给整屏取证。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ForcedState {
|
||||
/// 所有可交互控件按 hover 画。
|
||||
@@ -65,37 +116,11 @@ pub enum ForcedState {
|
||||
Pressed,
|
||||
}
|
||||
|
||||
impl ForcedState {
|
||||
/// 稳定名字(日志用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
ForcedState::Hover => "hover",
|
||||
ForcedState::Pressed => "pressed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: Backend::Auto,
|
||||
theme: None,
|
||||
screenshot: None,
|
||||
screenshot_theme: None,
|
||||
scale_factor: None,
|
||||
diff: None,
|
||||
diff_scan: 0,
|
||||
force_state: None,
|
||||
data_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
/// 从进程参数解析。
|
||||
/// 从进程参数解析(错误信息是人话,不 panic、不 `exit`)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 参数值不合法(未知的 `--renderer` / `--theme` / 非法 `--scale-factor`)时返回人话。
|
||||
/// 取值不合法(未知 `--renderer`/`--theme`/`--screen`、非法 `--scale-factor`)时返回人话。
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
Self::parse(std::env::args().skip(1))
|
||||
}
|
||||
@@ -173,6 +198,16 @@ impl Options {
|
||||
}
|
||||
});
|
||||
}
|
||||
"--screen" => {
|
||||
let raw = take(&mut args, inline, "--screen")?;
|
||||
options.screen = match raw.as_str() {
|
||||
"shell" => Screen::Shell,
|
||||
"gallery" => Screen::Gallery,
|
||||
other => {
|
||||
return Err(format!("--screen 只认 shell|gallery,收到 {other:?}"));
|
||||
}
|
||||
};
|
||||
}
|
||||
// 翻牌前的兼容参数:接受并忽略(见字段文档)
|
||||
"--data-dir" => {
|
||||
options.data_dir = Some(PathBuf::from(take(&mut args, inline, "--data-dir")?));
|
||||
@@ -184,10 +219,30 @@ impl Options {
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// 截图时实际使用的主题(截图专用参数优先)。
|
||||
/// 截图时实际使用的主题(**只表达用户显式给了什么**;默认值见 [`Options::capture_flavor`])。
|
||||
pub fn shot_flavor(&self) -> Option<SchemeFlavor> {
|
||||
self.screenshot_theme.or(self.theme)
|
||||
}
|
||||
|
||||
/// 本次进程真正要画的主题(**唯一取值口**)。
|
||||
///
|
||||
/// 截图模式(给了 `--screenshot`)**永远返回 `Some`**:`--screenshot-theme` > `--theme` > Light。
|
||||
/// 交互模式只有显式指定才有值(`None` = 等系统主题)。
|
||||
///
|
||||
/// ⚠️ **门禁依赖,不许把截图分支改回 `None`**:CI 的 runtime-smoke 打
|
||||
/// `--software-renderer --screenshot <png> --data-dir <dir>`(不带主题),xvfb 里没有桌面主题
|
||||
/// 会话,M68 的「等系统主题到达再出图」闸门在这条路径上会把 runner 挂到超时(不是红,更难查)。
|
||||
pub fn capture_flavor(&self) -> Option<SchemeFlavor> {
|
||||
match self.screenshot {
|
||||
Some(_) => Some(self.shot_flavor().unwrap_or(SchemeFlavor::Light)),
|
||||
None => self.shot_flavor(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否要问系统主题。截图模式**永远不问**——这就是「无主题环境不会卡住」的不变量。
|
||||
pub fn wants_system_theme(&self) -> bool {
|
||||
self.capture_flavor().is_none()
|
||||
}
|
||||
}
|
||||
|
||||
fn take(
|
||||
@@ -217,11 +272,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_auto_and_system_theme() {
|
||||
fn defaults_are_auto_system_theme_and_shell() {
|
||||
let options = parse(&[]).expect("空参数要能解析");
|
||||
assert_eq!(options.backend, Backend::Auto);
|
||||
assert_eq!(options.theme, None);
|
||||
assert_eq!(options.screenshot, None);
|
||||
assert_eq!((options.theme, options.screenshot), (None, None));
|
||||
assert_eq!(options.screen, Screen::Shell);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -235,29 +290,51 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_accepts_both_argv_forms() {
|
||||
fn both_argv_forms_are_accepted() {
|
||||
let spaced = parse(&["--screenshot", "out.png"]).unwrap();
|
||||
let inline = parse(&["--screenshot=out.png"]).unwrap();
|
||||
assert_eq!(spaced.screenshot, Some(PathBuf::from("out.png")));
|
||||
assert_eq!(spaced.screenshot, inline.screenshot);
|
||||
assert_eq!(
|
||||
parse(&["--diff", "a.png", "b.png"]).unwrap().diff,
|
||||
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--screen", "gallery"]).unwrap().screen,
|
||||
Screen::Gallery
|
||||
);
|
||||
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
|
||||
assert_eq!(
|
||||
parse(&["--force-state", "hover"]).unwrap().force_state,
|
||||
Some(ForcedState::Hover)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_theme_wins_over_theme() {
|
||||
let options = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
|
||||
assert_eq!(options.theme, Some(SchemeFlavor::Dark));
|
||||
assert_eq!(options.shot_flavor(), Some(SchemeFlavor::Light));
|
||||
let o = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
|
||||
assert_eq!(
|
||||
(o.theme, o.shot_flavor()),
|
||||
(Some(SchemeFlavor::Dark), Some(SchemeFlavor::Light))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_values_are_reported_not_panicked() {
|
||||
assert!(parse(&["--renderer=metal"]).is_err());
|
||||
assert!(parse(&["--theme=blue"]).is_err());
|
||||
assert!(parse(&["--scale-factor=0"]).is_err());
|
||||
assert!(parse(&["--scale-factor=abc"]).is_err());
|
||||
assert!(parse(&["--screenshot"]).is_err());
|
||||
assert!(parse(&["--diff", "only-one.png"]).is_err());
|
||||
assert!(parse(&["--nope"]).is_err());
|
||||
for args in [
|
||||
&["--renderer=metal"][..],
|
||||
&["--theme=blue"],
|
||||
&["--scale-factor=0"],
|
||||
&["--scale-factor=abc"],
|
||||
&["--screenshot"],
|
||||
&["--diff", "only-one.png"],
|
||||
&["--screen=settings"],
|
||||
&["--force-state=focus"],
|
||||
&["--diff-scan=-1"],
|
||||
&["--nope"],
|
||||
] {
|
||||
assert!(parse(args).is_err(), "{args:?} 应该报错");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -276,23 +353,42 @@ mod tests {
|
||||
assert_eq!(options.data_dir, Some(PathBuf::from("/d/tmp/x")));
|
||||
}
|
||||
|
||||
/// **门禁依赖**:CI 的 runtime-smoke 不带主题,截图模式必须有确定主题。
|
||||
/// 退化成 `None` 的话,xvfb 里的 runner 会挂到超时(不是红,更难查)。
|
||||
#[test]
|
||||
fn diff_scan_and_force_state_parse() {
|
||||
assert_eq!(parse(&["--diff-scan=2"]).unwrap().diff_scan, 2);
|
||||
fn screenshot_without_theme_defaults_to_light() {
|
||||
let options = parse(&["--screenshot", "out.png"]).unwrap();
|
||||
assert_eq!(options.shot_flavor(), None, "用户没给主题");
|
||||
assert_eq!(
|
||||
parse(&["--force-state", "hover"]).unwrap().force_state,
|
||||
Some(ForcedState::Hover)
|
||||
options.capture_flavor(),
|
||||
Some(SchemeFlavor::Light),
|
||||
"截图模式必须给确定主题(CI 冒烟依赖这条)"
|
||||
);
|
||||
assert!(parse(&["--force-state=focus"]).is_err());
|
||||
assert!(parse(&["--diff-scan=-1"]).is_err());
|
||||
assert!(!options.wants_system_theme(), "截图模式不许等系统主题");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_takes_two_paths() {
|
||||
let options = parse(&["--diff", "a.png", "b.png"]).unwrap();
|
||||
assert_eq!(
|
||||
options.diff,
|
||||
Some((PathBuf::from("a.png"), PathBuf::from("b.png")))
|
||||
);
|
||||
fn explicit_themes_still_win_in_screenshot_mode() {
|
||||
let dark = parse(&["--screenshot", "a.png", "--theme=dark"]).unwrap();
|
||||
assert_eq!(dark.capture_flavor(), Some(SchemeFlavor::Dark));
|
||||
|
||||
let both = parse(&[
|
||||
"--screenshot",
|
||||
"a.png",
|
||||
"--theme=dark",
|
||||
"--screenshot-theme=light",
|
||||
]);
|
||||
assert_eq!(both.unwrap().capture_flavor(), Some(SchemeFlavor::Light));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_mode_still_follows_the_system_theme() {
|
||||
let interactive = parse(&[]).unwrap();
|
||||
assert_eq!(interactive.capture_flavor(), None);
|
||||
assert!(interactive.wants_system_theme(), "交互模式等系统主题");
|
||||
|
||||
let pinned = parse(&["--theme=dark"]).unwrap();
|
||||
assert_eq!(pinned.capture_flavor(), Some(SchemeFlavor::Dark));
|
||||
assert!(!pinned.wants_system_theme());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! 装配与样式:五式共用的一条产线(尺寸 / 内边距 / 图标盒 / 状态层 / 描边)。
|
||||
//!
|
||||
//! 单独成文件的原因:`mod.rs` 已经是「对外契约 + 变体角色表」,把绘制细节放一起会顶到 400 行红线;
|
||||
//! 而且这条产线是**唯一**会把「令牌 → 像素」翻一次的地方,独立出来才好审。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{button, text, Row};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Pixels, Shadow};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::elevation::Elevation;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::super::layout;
|
||||
use super::{Geometry, Variant};
|
||||
|
||||
/// 一个按钮样式要的全部输入(避免 `style_closure` 长出七个位置参数)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct StyleSpec {
|
||||
/// 容器填充角色;`None` = 透明(Outlined / Text / StandardIcon)。
|
||||
pub fill: Option<Role>,
|
||||
/// 内容(文字 + 图标)角色。
|
||||
pub content: Role,
|
||||
/// 描边角色;`None` = 无描边。
|
||||
pub border: Option<Role>,
|
||||
/// 圆角档位(来自组件表,五式统一 `Full`)。
|
||||
pub shape: ShapeScale,
|
||||
/// 描边宽度(`Geometry::stroke`:有描边的变体 = `stroke::HAIRLINE`,其余 0)。
|
||||
pub stroke: f32,
|
||||
/// 圆角折算用的边长(药丸 = 可见高)。
|
||||
pub radius_side: f32,
|
||||
/// 强制态(gallery 取证);`None` = 跟随运行期的 `button::Status`。
|
||||
///
|
||||
/// **`Some(Enabled)` 与 `None` 不是一回事**:截图取证必须能强制「静息」,否则光标恰好停在
|
||||
/// 某个按钮上时那一格会被画成 hover(实测踩过:text 行 enabled 列出现不该有的灰底容器,
|
||||
/// 看起来像 Text 按钮自带容器)。
|
||||
pub forced: Option<Interaction>,
|
||||
}
|
||||
|
||||
/// 五式共用的装配:**只画一处**,五个变体函数都走这里。
|
||||
pub(super) fn assemble<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
variant: Variant,
|
||||
spec: super::Button<'a, M>,
|
||||
) -> Element<'a, M> {
|
||||
let geometry = Geometry::of(ui, variant, spec.leading.is_some());
|
||||
let (fill, content, border) = variant.roles();
|
||||
let forced = spec.state.or(if spec.on_press.is_none() {
|
||||
Some(Interaction::Disabled)
|
||||
} else {
|
||||
None
|
||||
});
|
||||
|
||||
// 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影)
|
||||
debug_assert!(
|
||||
!matches!(
|
||||
variant.elevation(),
|
||||
Elevation::Level3 | Elevation::Level4 | Elevation::Level5
|
||||
),
|
||||
"按钮家族不许有浮层高程"
|
||||
);
|
||||
// **内容色必须跟着态走**:禁用时文字/图标是 on-surface@38%,不是满档内容色。
|
||||
// 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color`
|
||||
// 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格
|
||||
// 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。
|
||||
let ink = content_color(ui, forced, content, fill, variant.disabled_content());
|
||||
|
||||
let label = text(spec.label)
|
||||
.size(ui.style(TypeRole::LabelLarge).size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(
|
||||
ui.style(TypeRole::LabelLarge).line_height_px,
|
||||
)))
|
||||
.font(ui.font_of(ui.emphasized(TypeRole::LabelLarge)))
|
||||
.color(ink);
|
||||
|
||||
let mut row = Row::new().align_y(Alignment::Center);
|
||||
if let Some(kind) = spec.leading {
|
||||
// Q9:图标一律经固定尺寸容器(按钮图标 16、IconButton 24 都不例外)
|
||||
row = row.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, ink),
|
||||
));
|
||||
}
|
||||
row = row.push(label);
|
||||
if let Some(kind) = spec.trailing {
|
||||
row = row.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, ink),
|
||||
));
|
||||
}
|
||||
let row = row.spacing(geometry.gap);
|
||||
|
||||
// **垂直居中必须在内容侧做**:iced 的 `button::layout` 把内容放在 padding-box 的左上角
|
||||
// (`layout::padded` 只 `move_to(padding.top)`,不做居中),所以内容自己撑满高度再居中。
|
||||
// 少了这一层,40 高的按钮里文字会贴上沿 10px(实测首版就是这样)。
|
||||
let content_box = iced::widget::container(row)
|
||||
.height(Length::Fill)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let style = style_closure(
|
||||
ui,
|
||||
StyleSpec {
|
||||
fill,
|
||||
content,
|
||||
border,
|
||||
shape: variant.shape(),
|
||||
stroke: geometry.stroke,
|
||||
radius_side: geometry.height,
|
||||
forced,
|
||||
},
|
||||
);
|
||||
|
||||
let element = button(content_box)
|
||||
.height(Length::Fixed(geometry.height))
|
||||
.padding(geometry.padding)
|
||||
.style(style);
|
||||
|
||||
match spec.on_press {
|
||||
Some(message) => element.on_press(message).into(),
|
||||
None => element.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容色(文字与图标共用):禁用态走令牌的 38%,其余态就是角色色。
|
||||
///
|
||||
/// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon`
|
||||
/// (图标按钮)都调它,两条路径不会再各自钉死满档色。
|
||||
/// 内容色:**禁用时的角色由调用方按变体给**([`super::Variant::disabled_content`] /
|
||||
/// [`super::IconVariant::disabled_content`])——A1 里 FilledTonal 与 IconButton 是 `OnSurface`,
|
||||
/// 其余是 `OnSurfaceVariant`,不能在这里统一写死。
|
||||
pub(super) fn content_color(
|
||||
ui: &Ui,
|
||||
forced: Option<Interaction>,
|
||||
content: Role,
|
||||
fill: Option<Role>,
|
||||
disabled_content: Role,
|
||||
) -> iced::Color {
|
||||
match forced {
|
||||
// 有容器:38% 叠在**12% 容器**上(Filled / Tonal / Elevated)
|
||||
Some(Interaction::Disabled) if fill.is_some() => {
|
||||
interaction::disabled_content_over_role(ui, disabled_content, true)
|
||||
}
|
||||
// 无容器:38% 直接叠在 **surface** 上(Text / Outlined / standard IconButton)
|
||||
Some(Interaction::Disabled) => {
|
||||
interaction::disabled_content_over_role(ui, disabled_content, false)
|
||||
}
|
||||
_ => ui.color(content),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 `button::style` 闭包:状态层 / 禁用 / 描边全在这里落地。
|
||||
pub(super) fn style_closure(
|
||||
ui: &Ui,
|
||||
spec: StyleSpec,
|
||||
) -> impl Fn(&iced::Theme, button::Status) -> button::Style + '_ {
|
||||
move |_theme: &iced::Theme, status| {
|
||||
// 强制态优先(gallery 状态矩阵),否则把 iced 的四态翻成 M3 五态
|
||||
let state = spec
|
||||
.forced
|
||||
.unwrap_or_else(|| Interaction::from_status(status));
|
||||
|
||||
let (background, text_color) = if state.is_disabled() {
|
||||
let (container, content) = interaction::disabled_colors(ui, Role::Surface);
|
||||
// 透明容器在禁用态依然透明(官方只改内容透明度)
|
||||
(spec.fill.map(|_| container), content)
|
||||
} else {
|
||||
// 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见)
|
||||
let background = match spec.fill {
|
||||
Some(role) => match state.layer() {
|
||||
Some(_) => interaction::overlay_color(ui, state, spec.content, role)
|
||||
.or(Some(ui.color(role))),
|
||||
None => Some(ui.color(role)),
|
||||
},
|
||||
None => transparent_state_color(ui, state, spec.content),
|
||||
};
|
||||
(background, ui.color(spec.content))
|
||||
};
|
||||
|
||||
let border_color = if state.is_disabled() {
|
||||
interaction::disabled_colors(ui, Role::Surface).0
|
||||
} else {
|
||||
spec.border
|
||||
.map_or_else(|| ui.color(Role::OutlineVariant), |role| ui.color(role))
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background: background.map(Background::Color),
|
||||
text_color,
|
||||
border: Border {
|
||||
color: if spec.border.is_some() {
|
||||
border_color
|
||||
} else {
|
||||
iced::Color::TRANSPARENT
|
||||
},
|
||||
// 线宽来自 `Geometry::stroke`(有描边的变体 = HAIRLINE,其余 0);
|
||||
// `spec.border.is_none()` 时颜色是透明的,宽度也就无所谓了。
|
||||
width: spec.stroke,
|
||||
radius: ui.radius(spec.shape, spec.radius_side),
|
||||
},
|
||||
// rubric §7:L0/L1 不用阴影(Elevated 的 L1 由容器色调差表达)
|
||||
shadow: Shadow::default(),
|
||||
// ⚠️ `snap` 目前**只有 wgpu 后端实现**;我们的验收图走 tiny-skia(CPU),
|
||||
// 所以这个 `true` 在验收图上不起作用——分隔线的「1dp 摊成 2 行」正是这个原因,
|
||||
// 不是「iced 没有 snap」(reviewer P2-4 纠正的正是我上一版报告里的说法)。
|
||||
snap: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 透明容器的状态层:`Outlined` / `Text` / `StandardIcon` 没有填充,可见反馈只能来自
|
||||
/// 叠在**内容角色**上的状态层——把它们当作「叠在表面色上」求值,得到不透明实色。
|
||||
///
|
||||
/// 之所以单独一个函数:这是 M3 里唯一「容器不存在但状态层存在」的情形,
|
||||
/// 三个变体共用一条口径,免得各自发挥。
|
||||
pub(super) fn transparent_state_color(
|
||||
ui: &Ui,
|
||||
state: Interaction,
|
||||
content: Role,
|
||||
) -> Option<iced::Color> {
|
||||
interaction::overlay_color(ui, state, content, Role::Surface)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! 按钮家族的**内部几何**:一个数值都不写,全部由 `unlucky_design` 的令牌推导。
|
||||
//!
|
||||
//! | 量 | 令牌 | 值(DESKTOP / 100% DPI) |
|
||||
//! |---|---|---|
|
||||
//! | 可见高 | `LayoutMetrics::control_height` | 40 `[源]` 12b 1.1 |
|
||||
//! | 圆角 | `ShapeScale::Full.radius_px(高)` | 20 `[源]` 12b 1.2 |
|
||||
//! | 左右内边距(无图标) | `Space::X6` | 24 / 24 `[源]` 12b 1.3 |
|
||||
//! | 左内边距(带前置图标) | `Space::X4` | 16 `[源]` 12b 1.4 |
|
||||
//! | 图标边长 | `IconSize::Button` | 16(**官方 18 → G14 偏离**) |
|
||||
//! | 图标↔文字 | `Space::X2` | 8 `[源]` 12b 1.6 |
|
||||
//! | 描边 | `unlucky_design::stroke::HAIRLINE`(`crate::theme::STROKE_PX` 同一真源) | 1 `[源]` rubric G7 |
|
||||
//! | IconButton 边长 | `LayoutMetrics::control_height` | 40 `[源]` 12b 2.1 |
|
||||
//! | IconButton 图标 / 边距 | `IconSize::Toolbar` / `Space::X2` | 24 / 8 `[源]` 12b 2.2 |
|
||||
//!
|
||||
//! **没有落点的两个参考值**(登记在报告里,不在此处写数):
|
||||
//!
|
||||
//! - 12b 1.7 最小宽 **58** → `G15`:iced 的 `Length` 没有 min-width 语义;按「24 + 24 内边距 +
|
||||
//! LabelLarge 二字」自然宽度 ≈ 76,实际不触发,故本 mission 不施加该约束;
|
||||
//! - 12b 1.6 图标 **18** → `G14`:令牌层 `IconSize::Button` 是 16、没有 18 档,
|
||||
//! 组件层又不许写字面量 → 先用 16 并登记 2px 偏离。
|
||||
//!
|
||||
//! 这两条是**能力缺口不是审美选择**,登记后由令牌层(`crates/design`)补档位。
|
||||
|
||||
use iced::Padding;
|
||||
|
||||
use unlucky_design::icons::IconSize;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::theme::{Ui, STROKE_PX};
|
||||
|
||||
use super::Variant;
|
||||
|
||||
/// 文字按钮的几何。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Geometry {
|
||||
/// 可见高(药丸圆角按它折算)。
|
||||
pub height: f32,
|
||||
/// 内边距(上下 0;左右按有无前置图标取 16 或 24)。
|
||||
pub padding: Padding,
|
||||
/// 图标盒边长。
|
||||
pub icon: f32,
|
||||
/// 图标 ↔ 文字间距。
|
||||
pub gap: f32,
|
||||
/// 描边宽度;`0` = 该式无描边。
|
||||
pub stroke: f32,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
/// 按变体 + 有没有前置图标求值(带图标时左内边距收到 16,右仍 24)。
|
||||
pub fn of(ui: &Ui, variant: Variant, leading_icon: bool) -> Self {
|
||||
let (_, _, border) = variant.roles();
|
||||
let start = if leading_icon {
|
||||
ui.space(Space::X4)
|
||||
} else {
|
||||
ui.space(Space::X6)
|
||||
};
|
||||
|
||||
Self {
|
||||
height: ui.metrics().control_height,
|
||||
padding: Padding {
|
||||
top: 0.0,
|
||||
right: ui.space(Space::X6),
|
||||
bottom: 0.0,
|
||||
left: start,
|
||||
},
|
||||
icon: IconSize::Button.px(),
|
||||
gap: ui.space(Space::X2),
|
||||
stroke: if border.is_some() { STROKE_PX } else { 0.0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IconButton 的几何(正方形,图标 24、边距 8)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct IconGeometry {
|
||||
/// 容器边长(40,命中区 48 由父级留白补足)。
|
||||
pub side: f32,
|
||||
/// 图标盒边长(24)——**必须**经 `layout::centered_fixed` 锁住,否则 Q9 会把字形撑满。
|
||||
pub icon: f32,
|
||||
/// 图标到容器边的默认边距(8;narrow 4 / wide 14 属变体参数,本 mission 不用)。
|
||||
pub padding: f32,
|
||||
}
|
||||
|
||||
impl IconGeometry {
|
||||
/// 从令牌求值。图标边长**由 `side − 2×padding` 推导**(40 − 16 = 24),
|
||||
/// 与 `IconSize::Toolbar`(24)互为校验:两者不等说明令牌层动了,单测会先红。
|
||||
pub fn of(ui: &Ui) -> Self {
|
||||
let side = ui.metrics().control_height;
|
||||
let padding = ui.space(Space::X2);
|
||||
Self {
|
||||
side,
|
||||
icon: side - 2.0 * padding,
|
||||
padding,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! 按钮家族(`research/12b` §1 五式 + §2 IconButton)。
|
||||
//!
|
||||
//! ## 参考值(全部 `[源]` = A1,`material3-android:1.4.0` 的 `*Tokens.kt` / `Button.kt`)
|
||||
//!
|
||||
//! | # | 检查项 | 参考值 | 本模块落点 |
|
||||
//! |---|---|---|---|
|
||||
//! | 1.1 | 容器可见高 | **40** | `LM.control_height`([`Geometry::height`]) |
|
||||
//! | 1.2 | 圆角 | **full = 高/2 = 20** | `ShapeScale::Full` + `Ui::radius` |
|
||||
//! | 1.3 | 水平内边距(无图标) | **24 / 24** | `Space::X6` |
|
||||
//! | 1.4 | 水平内边距(带前置图标) | **16(左)/ 24(右)** | `Space::X4` / `Space::X6` |
|
||||
//! | 1.5 | 字级 | **LabelLarge 14/20 w500** | `TypeRole::LabelLarge` 强调档 |
|
||||
//! | 1.6 | 图标 18 + 图标↔文字 8 | 图标见 **G14 偏离**(令牌只有 16);间距 `Space::X2` | [`Geometry`] |
|
||||
//! | 1.7 | 最小宽 58 | **G15**:iced 的 `Length` 无 min-width,不施加(实测见报告) | — |
|
||||
//! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 12%(A1 是 10%,D8)& 内容 38%;**内容角色按变体**([`Variant::disabled_content`]) | [`super::interaction`] |
|
||||
//! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated `SurfaceContainerLow`/`Primary`;Outlined 透明 + 1dp `OutlineVariant` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] |
|
||||
//! | 2.1 | IconButton 容器 | **40×40**、圆角 full | [`IconGeometry`] |
|
||||
//! | 2.2 | IconButton 图标 / 边距 | 图标 **24**、边距 **8** | [`IconGeometry`] |
|
||||
//! | 2.4 | Standard 各态**图标色不变** | 只有状态层变化 | [`Variant::roles`] 与态无关 |
|
||||
//! | 2.7 | IconButton 禁用 | 内容 **`OnSurface`@38%**(A1;见 [`IconVariant::disabled_content`]) | [`super::interaction::disabled_content_over_role`] |
|
||||
//! | 2.8 | 命中区 | 容器 40 + 四周各 4 = **48** | `LM.touch_target`(按钮自身不撑高) |
|
||||
//!
|
||||
//! ## 与契约 §2 的签名差异(**已登记,请 tower 确认**)
|
||||
//!
|
||||
//! 契约 §2 的示例签名是 `filled(ui, label, on_press)`——三个参数装不下
|
||||
//! 「五式 × 五态 × 前后双图标 × 禁用」。所以本模块的落地是**契约的加法版**:
|
||||
//! 泛型纪律一字不改(`M: Clone`,不碰 `app::Message`),但把前两个参数收进 [`Button`]:
|
||||
//!
|
||||
//! ```text
|
||||
//! 契约示例: filled(ui, "保存", Message::Save)
|
||||
//! 本模块: filled(ui, Button::new("保存").press(Message::Save))
|
||||
//! ```
|
||||
//!
|
||||
//! 差别只是「label + 消息」两件事从位置参数变成构造器参数,语义一一对应;
|
||||
//! 要图标/禁用/焦点取证时再加链式调用(`.leading(..)` / `Button::disabled(..)` / `.state(..)`),
|
||||
//! 不需要第二个签名。**这是本周唯一一处偏离契约示例的地方**,报告「决策」一节有登记。
|
||||
//!
|
||||
//! ## 为什么不用 iced 内置的 `theme::Button` 样式
|
||||
//!
|
||||
//! iced 的内置按钮样式只有 `primary/success/danger/text` 几档,且**不带 M3 的状态层 α**
|
||||
//! (读的是 `Palette` 现成色)。D30 要求 hover 8% / pressed 10% 可判定,所以样式自绘;
|
||||
//! 但**只画颜色与描边**——尺寸仍由 `button` 的 width/height/padding 决定,不手搓几何。
|
||||
|
||||
use iced::widget::button;
|
||||
use iced::{Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::Elevation;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::interaction::Interaction;
|
||||
use super::layout;
|
||||
|
||||
mod assemble;
|
||||
mod geometry;
|
||||
|
||||
pub use geometry::{Geometry, IconGeometry};
|
||||
|
||||
use assemble::{assemble, style_closure, StyleSpec};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// 按钮的五种视觉变体(12b §1.9 的色彩角色,一名一式)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Variant {
|
||||
/// 实心强调色:**每屏最多一个**(rubric K2)。
|
||||
Filled,
|
||||
/// 次强调(tonal):`SecondaryContainer`。
|
||||
Tonal,
|
||||
/// 带高程:`SurfaceContainerLow` + `Primary` 文本(L1 用色调差,不画阴影)。
|
||||
Elevated,
|
||||
/// 描边:透明容器 + 1dp `OutlineVariant`。
|
||||
Outlined,
|
||||
/// 纯文本:最低权重。
|
||||
Text,
|
||||
}
|
||||
|
||||
impl Variant {
|
||||
/// 五式全序(gallery 的矩阵按这个次序出图)。
|
||||
pub const ALL: [Variant; 5] = [
|
||||
Variant::Filled,
|
||||
Variant::Tonal,
|
||||
Variant::Elevated,
|
||||
Variant::Outlined,
|
||||
Variant::Text,
|
||||
];
|
||||
|
||||
/// 稳定名字(图例 / 文件名 / 失败信息用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Variant::Filled => "filled",
|
||||
Variant::Tonal => "tonal",
|
||||
Variant::Elevated => "elevated",
|
||||
Variant::Outlined => "outlined",
|
||||
Variant::Text => "text",
|
||||
}
|
||||
}
|
||||
|
||||
/// 该式在组件表里的档位(圆角/字级)。`Component` 没有 `Outlined/Elevated` 变体,
|
||||
/// 借用 `PrimaryButton`:三者判据值相同(`Full` 圆角 + `LabelLarge` + L0)。
|
||||
pub(crate) const fn component(self) -> Component {
|
||||
match self {
|
||||
Variant::Filled => Component::PrimaryButton,
|
||||
Variant::Tonal => Component::SecondaryButton,
|
||||
Variant::Text => Component::TextButton,
|
||||
Variant::Elevated | Variant::Outlined => Component::PrimaryButton,
|
||||
}
|
||||
}
|
||||
|
||||
/// 圆角档位(五式统一 `Full`,12b 1.2)。
|
||||
pub(crate) fn shape(self) -> ShapeScale {
|
||||
self.component().spec().shape
|
||||
}
|
||||
|
||||
/// **禁用态的内容角色**(A1,逐变体不同——别在这里统一猜)。
|
||||
///
|
||||
/// | 变体 | 禁用内容 |
|
||||
/// |---|---|
|
||||
/// | Filled / Elevated / Outlined / Text | `OnSurfaceVariant` @0.38 |
|
||||
/// | **FilledTonal** | **`OnSurface` @0.38** ← A1 的显式例外(`FilledTonalButtonTokens`) |
|
||||
///
|
||||
/// 出处:AAR `material3-android:1.4.0` 的 `javap -c`,`FilledButtonTokens.
|
||||
/// DisabledLabelTextColor = OnSurfaceVariant`、`FilledTonalButtonTokens` 同一字段 = `OnSurface`。
|
||||
pub(crate) const fn disabled_content(self) -> Role {
|
||||
match self {
|
||||
Variant::Tonal => Role::OnSurface,
|
||||
_ => Role::OnSurfaceVariant,
|
||||
}
|
||||
}
|
||||
|
||||
/// 高程:只有 Elevated 是 L1(`ElevatedButtonTokens.ContainerElevation`),其余 L0。
|
||||
///
|
||||
/// **不画阴影**:rubric §7「L0/L1 不应有可见阴影(用 tone 差)」,所以 L1 只体现为
|
||||
/// `SurfaceContainerLow` 容器色,`button::Style.shadow` 永远 default。
|
||||
pub(crate) const fn elevation(self) -> Elevation {
|
||||
match self {
|
||||
Variant::Elevated => Elevation::Level1,
|
||||
_ => Elevation::Level0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 色彩角色三元组 `(容器, 内容, 描边)`;`None` = 透明容器 / 无描边。
|
||||
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
|
||||
match self {
|
||||
Variant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
|
||||
Variant::Tonal => (
|
||||
Some(Role::SecondaryContainer),
|
||||
Role::OnSecondaryContainer,
|
||||
None,
|
||||
),
|
||||
Variant::Elevated => (Some(Role::SurfaceContainerLow), Role::Primary, None),
|
||||
Variant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::OutlineVariant)),
|
||||
Variant::Text => (None, Role::OnSurfaceVariant, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// IconButton 的三种容器变体(12b §2.5)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum IconVariant {
|
||||
/// 标准:容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。
|
||||
Standard,
|
||||
/// 实心:`Primary` + `OnPrimary`。
|
||||
Filled,
|
||||
/// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。
|
||||
Tonal,
|
||||
}
|
||||
|
||||
impl IconVariant {
|
||||
/// 三式全序(gallery 顺序)。
|
||||
pub const ALL: [IconVariant; 3] = [
|
||||
IconVariant::Standard,
|
||||
IconVariant::Filled,
|
||||
IconVariant::Tonal,
|
||||
];
|
||||
|
||||
/// 稳定名字。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
IconVariant::Standard => "standard",
|
||||
IconVariant::Filled => "filled",
|
||||
IconVariant::Tonal => "tonal",
|
||||
}
|
||||
}
|
||||
|
||||
/// **禁用态图标色**:三式都是 `OnSurface` @0.38 —— A1
|
||||
/// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。
|
||||
pub(crate) const fn disabled_content(self) -> Role {
|
||||
Role::OnSurface
|
||||
}
|
||||
|
||||
/// 色彩角色三元组(同 [`Variant::roles`] 的口径)。
|
||||
pub(crate) const fn roles(self) -> (Option<Role>, Role, Option<Role>) {
|
||||
match self {
|
||||
IconVariant::Standard => (None, Role::OnSurfaceVariant, None),
|
||||
IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None),
|
||||
IconVariant::Tonal => (
|
||||
Some(Role::SecondaryContainer),
|
||||
Role::OnSecondaryContainer,
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个按钮的装配描述(五式共用;**不持有任何状态所有权**)。
|
||||
///
|
||||
/// `on_press: Option<M>` 就是 M3 的 enabled/disabled 开关——`None` = 禁用,
|
||||
/// 与 `button::on_press_maybe` 的语义天然对齐(省掉一个 `disabled: bool` 字段,
|
||||
/// 也就不会出现「有消息但画成禁用」这种自相矛盾的状态)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Button<'a, M> {
|
||||
label: &'a str,
|
||||
on_press: Option<M>,
|
||||
leading: Option<IconKind>,
|
||||
trailing: Option<IconKind>,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> Button<'a, M> {
|
||||
/// 一个可点的按钮。
|
||||
pub fn new(label: &'a str, on_press: M) -> Self {
|
||||
Self {
|
||||
label,
|
||||
on_press: Some(on_press),
|
||||
leading: None,
|
||||
trailing: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个禁用的按钮(无消息)。禁用不是「另一种颜色」,见 `interaction::disabled_colors`。
|
||||
pub fn disabled(label: &'a str) -> Self {
|
||||
Self {
|
||||
label,
|
||||
on_press: None,
|
||||
leading: None,
|
||||
trailing: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 前置图标(12b 1.4:带图标时左内边距收到 16)。
|
||||
pub fn leading(mut self, kind: IconKind) -> Self {
|
||||
self.leading = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 后置图标(右内边距保持 24)。
|
||||
pub fn trailing(mut self, kind: IconKind) -> Self {
|
||||
self.trailing = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 强制视觉态(`--screen=gallery` 的状态矩阵取证用;运行期不要调)。
|
||||
pub fn state(mut self, state: Interaction) -> Self {
|
||||
self.state = Some(state);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Filled 按钮(实心 `Primary`)。
|
||||
pub fn filled<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Filled, button)
|
||||
}
|
||||
|
||||
/// Tonal 按钮(`SecondaryContainer`)。
|
||||
pub fn tonal<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Tonal, button)
|
||||
}
|
||||
|
||||
/// Elevated 按钮(`SurfaceContainerLow` + `Primary` 文本,L1 用色调差)。
|
||||
pub fn elevated<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Elevated, button)
|
||||
}
|
||||
|
||||
/// Outlined 按钮(透明 + 1dp `OutlineVariant`)。
|
||||
pub fn outlined<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Outlined, button)
|
||||
}
|
||||
|
||||
/// Text 按钮(无容器、无描边)。
|
||||
pub fn text<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> {
|
||||
assemble(ui, Variant::Text, button)
|
||||
}
|
||||
|
||||
/// IconButton(standard / filled / tonal):40×40、图标 24、命中区 48。
|
||||
///
|
||||
/// **图标语汇纪律(视觉 reviewer P2-3)**:同一个控件里**不混用**填充字形(`IconPaint::Fill`,
|
||||
/// 如 `Play`/`Stop`/`More`)与描边字形(`Stroke`)——混着放会让线宽看起来忽粗忽细。
|
||||
/// 允许实心字形,但只能出现在「主操作」这一处(例如播放按钮),其余位置一律描边;
|
||||
/// gallery 的样例因此统一用描边图标(`Download` + `ChevronRight`),并有单测钉住。
|
||||
///
|
||||
/// `on_press: Option<M>`(`None` = 禁用)与 `state` 分开给:前者是语义,后者是取证开关。
|
||||
pub fn icon<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
variant: IconVariant,
|
||||
kind: IconKind,
|
||||
on_press: Option<M>,
|
||||
state: Option<Interaction>,
|
||||
) -> Element<'a, M> {
|
||||
let geometry = IconGeometry::of(ui);
|
||||
let (fill, content, border) = variant.roles();
|
||||
let forced = state.or(if on_press.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(Interaction::Disabled)
|
||||
});
|
||||
|
||||
// Q9:图标**必须在固定尺寸的中层容器里**,否则 Button 的 Fill 约束会让 usvg 按 Contain
|
||||
// 撑满内容区(M68:24 的字形渲染成 46px、线宽 1.5 → 2.9)。
|
||||
// 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中,
|
||||
// 所以「居中」这件事必须由容器自己写死,别指望 button。
|
||||
// 内容色跟态走(禁用 = on-surface@38%),见 `assemble::content_color` 的说明
|
||||
let glyph = assemble::content_color(ui, forced, content, fill, variant.disabled_content());
|
||||
let icon_box =
|
||||
layout::centered_fixed(geometry.side, geometry.side, icons::view::<M>(kind, glyph));
|
||||
|
||||
let style = style_closure(
|
||||
ui,
|
||||
StyleSpec {
|
||||
fill,
|
||||
content,
|
||||
border,
|
||||
// 方形图标按钮同样是 full 圆角(40 → 20),档位取自组件表
|
||||
shape: Component::IconButton.spec().shape,
|
||||
stroke: 0.0,
|
||||
radius_side: geometry.side,
|
||||
forced,
|
||||
},
|
||||
);
|
||||
|
||||
let element = button(icon_box)
|
||||
.width(Length::Fixed(geometry.side))
|
||||
.height(Length::Fixed(geometry.side))
|
||||
.padding(Padding::default())
|
||||
.style(style);
|
||||
|
||||
match on_press {
|
||||
Some(message) => element.on_press(message).into(),
|
||||
None => element.into(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//! 按钮家族的单测(几何值 / 色彩角色 / 样式闭包)。
|
||||
//!
|
||||
//! 这里测的是「函数与样式结构」;**像素级**的几何(40 高、20 圆角、24 图标盒)
|
||||
//! 由 `gallery/probe.rs` 对着真截图量——M68 的教训是「函数对、图错」,两者缺一不可。
|
||||
|
||||
use iced::widget::button;
|
||||
use iced::{Background, Shadow};
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::elevation::Elevation;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::assemble::StyleSpec;
|
||||
use super::{style_closure, Geometry, IconGeometry, IconVariant, Variant};
|
||||
use crate::theme::{Families, Ui, STROKE_PX};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(
|
||||
flavor,
|
||||
unlucky_design::typography::Platform::Windows,
|
||||
FAMILIES,
|
||||
)
|
||||
}
|
||||
|
||||
fn light() -> Ui {
|
||||
ui(SchemeFlavor::Light)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Msg {
|
||||
Pressed(&'static str),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_variant_builds_and_disabled_has_no_message() {
|
||||
let ui = light();
|
||||
let _ = super::filled(&ui, super::Button::new("Filled", Msg::Pressed("f")));
|
||||
let _ = super::tonal(&ui, super::Button::new("Tonal", Msg::Pressed("t")));
|
||||
let _ = super::elevated(&ui, super::Button::new("Elevated", Msg::Pressed("e")));
|
||||
let _ = super::outlined(&ui, super::Button::new("Outlined", Msg::Pressed("o")));
|
||||
let _ = super::text(&ui, super::Button::new("Text", Msg::Pressed("x")));
|
||||
let _ = super::filled::<Msg>(&ui, super::Button::disabled("Disabled"));
|
||||
let _ = super::filled(
|
||||
&ui,
|
||||
super::Button::new("Icons", Msg::Pressed("i"))
|
||||
.leading(IconKind::Plus)
|
||||
.trailing(IconKind::ChevronRight),
|
||||
);
|
||||
}
|
||||
|
||||
/// 12b 1.1~1.6 + 2.1~2.2:几何逐值钉住(每个值都要能指回令牌)。
|
||||
#[test]
|
||||
fn geometry_matches_the_reference_values() {
|
||||
let ui = light();
|
||||
let geometry = Geometry::of(&ui, Variant::Filled, false);
|
||||
assert_eq!(geometry.height, 40.0, "12b 1.1 可见高 40");
|
||||
assert_eq!(geometry.height, ui.metrics().control_height);
|
||||
assert_eq!(
|
||||
ui.radius(unlucky_design::shape::ShapeScale::Full, geometry.height)
|
||||
.top_left,
|
||||
20.0,
|
||||
"12b 1.2 full 圆角 = 高/2"
|
||||
);
|
||||
assert_eq!(geometry.padding.left, 24.0, "12b 1.3 左 24");
|
||||
assert_eq!(geometry.padding.right, 24.0, "12b 1.3 右 24");
|
||||
assert_eq!(geometry.padding.top, 0.0);
|
||||
assert_eq!(geometry.gap, 8.0, "12b 1.6 图标↔文字 8");
|
||||
assert_eq!(geometry.gap, ui.space(Space::X2));
|
||||
assert_eq!(geometry.stroke, 0.0, "实心按钮无描边");
|
||||
|
||||
let with_icon = Geometry::of(&ui, Variant::Filled, true);
|
||||
assert_eq!(with_icon.padding.left, 16.0, "12b 1.4 带图标左内边距 16");
|
||||
assert_eq!(with_icon.padding.right, 24.0, "带图标右内边距不动");
|
||||
|
||||
let outlined = Geometry::of(&ui, Variant::Outlined, false);
|
||||
assert_eq!(outlined.stroke, STROKE_PX, "rubric G7 全站 1px");
|
||||
assert_eq!(outlined.stroke, 1.0);
|
||||
|
||||
// 图标 16 是**令牌值**;官方 18 是 G14 登记的偏离(不在这里假装相等)
|
||||
assert_eq!(geometry.icon, 16.0, "IconSize::Button");
|
||||
assert_eq!(geometry.height % 4.0, 0.0, "可见高必须在 4px 栅格上(D1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_button_geometry_matches_the_reference() {
|
||||
let ui = light();
|
||||
let geometry = IconGeometry::of(&ui);
|
||||
assert_eq!((geometry.side, geometry.icon), (40.0, 24.0), "12b 2.1/2.2");
|
||||
assert_eq!(
|
||||
ui.radius(unlucky_design::shape::ShapeScale::Full, geometry.side)
|
||||
.top_left,
|
||||
20.0,
|
||||
"40×40 的 full 圆角 = 20"
|
||||
);
|
||||
assert_eq!(geometry.padding, 8.0, "12b 2.2 默认边距 8");
|
||||
// 命中区 = 容器 40 + 四周各 4(12b 2.8);按钮自身不撑高
|
||||
assert_eq!(
|
||||
geometry.side + 2.0 * ui.space(Space::X1),
|
||||
ui.metrics().touch_target,
|
||||
"40 + 8 = 48 命中下限"
|
||||
);
|
||||
}
|
||||
|
||||
/// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。
|
||||
///
|
||||
/// gallery 的样例图标(前置/后置)必须同为 `IconPaint::Stroke`;允许实心字形,但只能作为
|
||||
/// 「主操作」单独出场的图标(例如播放键),不能与描边图标同处一颗按钮。
|
||||
#[test]
|
||||
fn sample_icons_never_mix_fill_and_stroke() {
|
||||
use unlucky_design::icons::IconPaint;
|
||||
|
||||
let sample = [IconKind::Download, IconKind::ChevronRight];
|
||||
for kind in sample {
|
||||
assert_eq!(
|
||||
kind.icon().paint,
|
||||
IconPaint::Stroke,
|
||||
"{} 是填充字形,不能和描边图标混在同一颗按钮里",
|
||||
kind.name()
|
||||
);
|
||||
}
|
||||
// 已知的实心字形(留档,下次有人拿它们当按钮图标时这条会提醒)
|
||||
assert_eq!(IconKind::Play.icon().paint, IconPaint::Fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_variants_have_distinct_colour_roles() {
|
||||
let mut seen = Vec::new();
|
||||
for variant in Variant::ALL {
|
||||
let roles = variant.roles();
|
||||
assert!(
|
||||
!seen.contains(&roles),
|
||||
"{variant:?} 与别的变体色彩角色重复了"
|
||||
);
|
||||
seen.push(roles);
|
||||
}
|
||||
|
||||
// 12b 1.9 逐条角色名核对
|
||||
assert_eq!(
|
||||
Variant::Filled.roles(),
|
||||
(
|
||||
Some(unlucky_design::color::Role::Primary),
|
||||
unlucky_design::color::Role::OnPrimary,
|
||||
None
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Tonal.roles(),
|
||||
(
|
||||
Some(unlucky_design::color::Role::SecondaryContainer),
|
||||
unlucky_design::color::Role::OnSecondaryContainer,
|
||||
None
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Elevated.roles(),
|
||||
(
|
||||
Some(unlucky_design::color::Role::SurfaceContainerLow),
|
||||
unlucky_design::color::Role::Primary,
|
||||
None
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Outlined.roles(),
|
||||
(
|
||||
None,
|
||||
unlucky_design::color::Role::OnSurfaceVariant,
|
||||
Some(unlucky_design::color::Role::OutlineVariant)
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
Variant::Text.roles(),
|
||||
(None, unlucky_design::color::Role::OnSurfaceVariant, None)
|
||||
);
|
||||
|
||||
for variant in Variant::ALL {
|
||||
let expected = if variant == Variant::Elevated {
|
||||
Elevation::Level1
|
||||
} else {
|
||||
Elevation::Level0
|
||||
};
|
||||
assert_eq!(variant.elevation(), expected, "{variant:?} 高程");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_button_variants_match_the_reference_roles() {
|
||||
use unlucky_design::color::Role;
|
||||
assert_eq!(
|
||||
IconVariant::Standard.roles(),
|
||||
(None, Role::OnSurfaceVariant, None)
|
||||
);
|
||||
assert_eq!(
|
||||
IconVariant::Filled.roles(),
|
||||
(Some(Role::Primary), Role::OnPrimary, None)
|
||||
);
|
||||
assert_eq!(
|
||||
IconVariant::Tonal.roles(),
|
||||
(
|
||||
Some(Role::SecondaryContainer),
|
||||
Role::OnSecondaryContainer,
|
||||
None
|
||||
)
|
||||
);
|
||||
|
||||
let ui = light();
|
||||
for variant in IconVariant::ALL {
|
||||
let _ = super::icon::<Msg>(&ui, variant, IconKind::Settings, None, None);
|
||||
let _ = super::icon(
|
||||
&ui,
|
||||
variant,
|
||||
IconKind::Settings,
|
||||
Some(Msg::Pressed("i")),
|
||||
None,
|
||||
);
|
||||
// 五态取证开关都能出元素(gallery 矩阵依赖它)
|
||||
for state in Interaction::ALL {
|
||||
let _ = super::icon(
|
||||
&ui,
|
||||
variant,
|
||||
IconKind::Settings,
|
||||
Some(Msg::Pressed("i")),
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spec_of(variant: Variant, forced: Option<Interaction>) -> StyleSpec {
|
||||
let (fill, content, border) = variant.roles();
|
||||
StyleSpec {
|
||||
fill,
|
||||
content,
|
||||
border,
|
||||
shape: variant.shape(),
|
||||
stroke: Geometry::of(&light(), variant, false).stroke,
|
||||
radius_side: Geometry::of(&light(), variant, false).height,
|
||||
forced,
|
||||
}
|
||||
}
|
||||
|
||||
/// **渲染层**:样式闭包吐给 iced 的圆角/描边/阴影必须是令牌推导值。
|
||||
#[test]
|
||||
fn outlined_style_draws_hairline_border_and_full_radius() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Outlined, None));
|
||||
|
||||
let idle = style(&ui.theme(), button::Status::Active);
|
||||
assert_eq!(idle.background, None, "Outlined 静息容器必须透明");
|
||||
assert_eq!(idle.border.width, STROKE_PX, "1dp 描边");
|
||||
assert_eq!(idle.border.radius.top_left, 20.0, "full 圆角");
|
||||
assert_eq!(
|
||||
idle.border.color,
|
||||
ui.color(unlucky_design::color::Role::OutlineVariant)
|
||||
);
|
||||
assert_eq!(idle.shadow, Shadow::default(), "L0 不许有阴影");
|
||||
|
||||
// 透明容器也要看得见 hover:状态层叠到表面色上
|
||||
let hovered = style(&ui.theme(), button::Status::Hovered);
|
||||
assert_ne!(hovered.background, None, "透明容器的 hover 必须有可见变化");
|
||||
assert_ne!(hovered.background, idle.background);
|
||||
|
||||
let disabled = style(&ui.theme(), button::Status::Disabled);
|
||||
let (container, content) =
|
||||
interaction::disabled_colors(&ui, unlucky_design::color::Role::Surface);
|
||||
assert_eq!(disabled.text_color, content, "禁用内容 = 令牌 38%");
|
||||
assert_eq!(disabled.border.color, container, "禁用描边 = 令牌 12%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_button_state_layers_are_visible_and_ordered() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Filled, None));
|
||||
|
||||
let idle = style(&ui.theme(), button::Status::Active);
|
||||
let hovered = style(&ui.theme(), button::Status::Hovered);
|
||||
let pressed = style(&ui.theme(), button::Status::Pressed);
|
||||
|
||||
assert_eq!(
|
||||
idle.background,
|
||||
Some(Background::Color(
|
||||
ui.color(unlucky_design::color::Role::Primary)
|
||||
))
|
||||
);
|
||||
assert_ne!(hovered.background, idle.background, "hover 8%");
|
||||
assert_ne!(
|
||||
pressed.background, hovered.background,
|
||||
"pressed 10% ≠ hover 8%"
|
||||
);
|
||||
assert_eq!(
|
||||
hovered.text_color,
|
||||
ui.color(unlucky_design::color::Role::OnPrimary),
|
||||
"12b 2.4/4.7:状态层不换内容色"
|
||||
);
|
||||
assert_eq!(idle.border.width, 0.0, "实心按钮无描边");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_state_overrides_the_runtime_status() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Tonal, Some(Interaction::Hovered)));
|
||||
// 运行期说 Active,但取证要 hover → 必须画 hover
|
||||
assert_eq!(
|
||||
style(&ui.theme(), button::Status::Active).background,
|
||||
style(&ui.theme(), button::Status::Hovered).background
|
||||
);
|
||||
assert_ne!(
|
||||
style(&ui.theme(), button::Status::Active).background,
|
||||
Some(Background::Color(
|
||||
ui.color(unlucky_design::color::Role::SecondaryContainer)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elevated_uses_a_tone_not_a_shadow() {
|
||||
let ui = light();
|
||||
let style = style_closure(&ui, spec_of(Variant::Elevated, None));
|
||||
let idle = style(&ui.theme(), button::Status::Active);
|
||||
assert_eq!(idle.shadow, Shadow::default(), "rubric §7:L1 不画阴影");
|
||||
assert_eq!(
|
||||
idle.background,
|
||||
Some(Background::Color(
|
||||
ui.color(unlucky_design::color::Role::SurfaceContainerLow)
|
||||
)),
|
||||
"L1 由 SurfaceContainerLow 的色调差表达"
|
||||
);
|
||||
assert_eq!(
|
||||
idle.text_color,
|
||||
ui.color(unlucky_design::color::Role::Primary)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! 分隔线(`research/15` §22,A1:`DividerTokens.kt` / `Divider.kt` / `ListTokens.kt`)。
|
||||
//!
|
||||
//! | # | 检查项 | 参考值 | 落点 |
|
||||
//! |---|---|---|---|
|
||||
//! | 22.1 | 厚度 | **1dp** → 1px ±0 @100% DPI | `LayoutMetrics::divider_thickness` = [`STROKE_PX`] |
|
||||
//! | 22.2 | 颜色 | **`OutlineVariant`**(不是 `Outline`,那个更深、会抢层级) | `Role::OutlineVariant` |
|
||||
//! | 22.3 | 缩进 | 列表内 **左 16 / 右 16**;区块分隔 **0(通栏)** | [`list`] / [`full`] |
|
||||
//! | 22.4 | 用量 | 同屏同类线 ≤3;同容器内不得既有描边又有分隔线 | 页面线自查(本模块不强制) |
|
||||
//! | 22.5 | 何时不用 | tone 能分层就不画线;纵向分隔本项目不用 | 文档约定 |
|
||||
//!
|
||||
//! ## 语义纪律(22.5 的一句话口径)
|
||||
//!
|
||||
//! **分隔线是兜底手段,不是排版手段**:能靠 `Surface` / `SurfaceContainerLow` /
|
||||
//! `SurfaceContainerHigh` 的色差把层级说清的地方就不画线;卡片已经有 1px `OutlineVariant`
|
||||
//! 描边时,卡片**之间**不再画线(同时出现 = 双重分隔,rubric K3 那类廉价感)。
|
||||
//!
|
||||
//! ## 为什么只有两个入口
|
||||
//!
|
||||
//! §22.3 只承认两种缩进:通栏(区块分隔)与列表内 16。第三种缩进一定是「页面自己想出来的」,
|
||||
//! 所以不提供任意数值入口——真要第三种,先在 `SubAgentsTalk/` 登记。
|
||||
|
||||
use iced::widget::{container, space};
|
||||
use iced::{Background, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
|
||||
use crate::theme::{Ui, STROKE_PX};
|
||||
|
||||
use super::layout;
|
||||
|
||||
/// 通栏分隔线(区块分隔:0 缩进)。
|
||||
pub fn full<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
line(ui)
|
||||
}
|
||||
|
||||
/// 列表内分隔线:左缩进 `Space::X4`(16),右侧到底(`ListTokens.DividerLeadingSpace`)。
|
||||
///
|
||||
/// 右缩进按官方也是 16,但列表行本身已有 16 的内边距时再缩一次会变成 32——
|
||||
/// 本项目把「行内边距」交给行,本函数只负责**线相对行**的缩进,故右端通到行尾。
|
||||
pub fn list<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
layout::inset_start(ui.space(Space::X4), line(ui))
|
||||
}
|
||||
|
||||
/// 一条 1px 的横线(厚度/颜色都只有一处定义)。
|
||||
fn line<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
container(space::horizontal())
|
||||
.width(Length::Fill)
|
||||
.height(thickness(ui))
|
||||
.style(|_theme| container::Style {
|
||||
background: Some(Background::Color(ui.color(Role::OutlineVariant))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 线厚(= `LayoutMetrics::divider_thickness` = `stroke::HAIRLINE`,三者同一真源)。
|
||||
pub fn thickness(ui: &Ui) -> f32 {
|
||||
debug_assert_eq!(
|
||||
ui.metrics().divider_thickness,
|
||||
STROKE_PX,
|
||||
"分隔线厚度必须是一像素线的唯一真源"
|
||||
);
|
||||
ui.metrics().divider_thickness
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
fn ui() -> Ui {
|
||||
Ui::with_platform(
|
||||
SchemeFlavor::Light,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 22.1:厚度 1(不是 2,也不是 1.5)。
|
||||
#[test]
|
||||
fn thickness_is_exactly_one_pixel() {
|
||||
let ui = ui();
|
||||
assert_eq!(thickness(&ui), 1.0);
|
||||
assert_eq!(thickness(&ui), STROKE_PX);
|
||||
assert_eq!(thickness(&ui), ui.metrics().divider_thickness);
|
||||
}
|
||||
|
||||
/// 22.3:列表内缩进 16,来自 `Space::X4` 而不是字面量。
|
||||
#[test]
|
||||
fn list_inset_comes_from_the_spacing_token() {
|
||||
let ui = ui();
|
||||
assert_eq!(ui.space(Space::X4), 16.0, "ListTokens.DividerLeadingSpace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_forms_build() {
|
||||
let ui = ui();
|
||||
let _: Element<'_, ()> = full(&ui);
|
||||
let _: Element<'_, ()> = list(&ui);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! **反馈类家族(桩)**:由后续 mission 填充(§12 Progress / §14 Snackbar / §7 Dialog / §15 EmptyState)。
|
||||
//!
|
||||
//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**:
|
||||
//! 家族模块已经在 `mod.rs` 里声明好,`gallery/feedback.rs` 也已经在 `gallery/mod.rs` 里声明好,
|
||||
//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。
|
||||
//!
|
||||
//! 填充时请照抄按钮家族的分层:
|
||||
//!
|
||||
//! - 几何进 `feedback/geometry.rs`(数值从 `Space` / `ShapeScale` / `LayoutMetrics` 取,
|
||||
//! 缺令牌登记 G 编号——Dialog 的 280/560 宽、Snackbar 的 48/68 高目前都还没有令牌);
|
||||
//! - 状态层只能走 [`crate::app::components::interaction`];
|
||||
//! - Dialog/Snackbar 是**浮层**:阴影只在这两处允许(rubric K3),且单层。
|
||||
|
||||
use iced::widget::{container, text};
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 占位 demo(ASCII 文案,理由同 `selection::demo`)。
|
||||
pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text("feedback · progress / snackbar / dialog / empty state — pending")
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.padding(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 按钮家族的状态矩阵(D30:浅/深 × 五态)。
|
||||
//!
|
||||
//! ## 矩阵形状
|
||||
//!
|
||||
//! ```text
|
||||
//! | enabled | hovered | pressed | focused | disabled |
|
||||
//! filled | [btn] | [btn] | [btn] | [btn] | [btn] |
|
||||
//! tonal | ... (下同)
|
||||
//! elevated |
|
||||
//! outlined |
|
||||
//! text |
|
||||
//! icon·standard | [ico] | ...
|
||||
//! icon·filled |
|
||||
//! icon·tonal |
|
||||
//! ```
|
||||
//!
|
||||
//! 40 个单元格共 5 列 × 8 行,一屏(1180×760)装不下,靠 `gallery/mod.rs` 的 scrollable 滚动;
|
||||
//! 截图取的是**整窗**,所以首屏能看到按钮家族的全部 5 行(图标行可能需要滚动,报告里会说明)。
|
||||
//!
|
||||
//! ## 为什么每格都强制状态
|
||||
//!
|
||||
//! iced 的 `button::Status` 只由**真实鼠标**驱动,截图时没法让 40 个按钮同时 hover。
|
||||
//! 组件层的 `Button::state(...)` 就是为这件事开的取证开关(`--force-state` 只能给整屏一种态)。
|
||||
//! 强制值**只影响画什么**,不动任何状态机——所以矩阵图里的五态与运行期五态逐像素同源。
|
||||
|
||||
use iced::widget::{column, row};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::buttons::{self, Button, IconVariant, Variant};
|
||||
use super::super::interaction::Interaction;
|
||||
use super::{cell_width, heading, row_label, state_cells, DemoMessage};
|
||||
|
||||
/// 按钮家族整段(标题 + 列头 + 5 行文字按钮 + 3 行图标按钮 + 一条图例说明)。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let legend = ui.style(TypeRole::BodySmall);
|
||||
|
||||
let mut rows = column![header_row(ui)].spacing(ui.space(Space::X2));
|
||||
|
||||
for variant in Variant::ALL {
|
||||
let cells = Interaction::ALL
|
||||
.iter()
|
||||
.map(|state| cell(ui, text_button(ui, variant, *state)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rows = rows.push(
|
||||
row![
|
||||
row_label(ui, variant.name()),
|
||||
row(cells).spacing(ui.space(Space::X2))
|
||||
]
|
||||
.spacing(ui.space(Space::X2)),
|
||||
);
|
||||
}
|
||||
|
||||
for variant in IconVariant::ALL {
|
||||
let cells = Interaction::ALL
|
||||
.iter()
|
||||
.map(|state| cell(ui, icon_button(ui, variant, *state)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rows = rows.push(
|
||||
row![
|
||||
row_label(ui, variant.name()),
|
||||
row(cells).spacing(ui.space(Space::X2))
|
||||
]
|
||||
.spacing(ui.space(Space::X2)),
|
||||
);
|
||||
}
|
||||
|
||||
column![
|
||||
heading(
|
||||
ui,
|
||||
"buttons · filled / tonal / elevated / outlined / text + icon",
|
||||
"12b §1/§2:高 40 · 圆角 full(20) · 内边距 24(带图标 16/24)· 图标 16[G14] 间距 8 · \
|
||||
hover 8% / focus 10% / pressed 10% / disabled 容器 12% 内容 38%"
|
||||
),
|
||||
rows,
|
||||
iced::widget::text(
|
||||
"图例:列 = 五态(focus 与 pressed 同 α 10%,看起来一样是规范要求);\
|
||||
行 = 变体。虚线不表示任何东西,只是矩阵栅格。"
|
||||
)
|
||||
.size(legend.size_px)
|
||||
.font(ui.font_of(legend))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 列头(五态名)。
|
||||
fn header_row<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let style = ui.style(TypeRole::LabelSmall);
|
||||
let mut head = row![row_label(ui, "")].spacing(ui.space(Space::X2));
|
||||
|
||||
for (name, width) in state_cells(ui) {
|
||||
head = head.push(
|
||||
iced::widget::container(
|
||||
iced::widget::text(name)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.width(Length::Fixed(width)),
|
||||
);
|
||||
}
|
||||
|
||||
head.into()
|
||||
}
|
||||
|
||||
/// 一个矩阵单元格(固定宽,内容左对齐;高度只由按钮自己决定)。
|
||||
fn cell<'a>(ui: &'a Ui, content: Element<'a, DemoMessage>) -> Element<'a, DemoMessage> {
|
||||
iced::widget::container(content)
|
||||
.width(Length::Fixed(cell_width(ui)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 文字按钮的一格(强制成 `state`)。
|
||||
fn text_button<'a>(ui: &'a Ui, variant: Variant, state: Interaction) -> Element<'a, DemoMessage> {
|
||||
// 标签用变体名 + 首字母大写:图上要能一眼对上左栏,且不引入 i18n 依赖(gallery 不是产品页)
|
||||
let label = match variant {
|
||||
Variant::Filled => "Filled",
|
||||
Variant::Tonal => "Tonal",
|
||||
Variant::Elevated => "Elevated",
|
||||
Variant::Outlined => "Outlined",
|
||||
Variant::Text => "Text",
|
||||
};
|
||||
|
||||
// 五态共用**同一套**装配,只有「有没有消息」随禁用变:
|
||||
// 早先禁用格单独走 `Button::disabled(label)`,把前后图标一起丢了,于是 disabled 列看起来
|
||||
// 像另一个组件(视觉 reviewer P2-3)。
|
||||
let mut spec = if state.is_disabled() {
|
||||
Button::disabled(label)
|
||||
} else {
|
||||
Button::new(label, DemoMessage::Pressed(label))
|
||||
}
|
||||
.state(state);
|
||||
if variant != Variant::Text {
|
||||
// 两个样例都必须是**描边**字形:`Play` 是填充字形,和 `ChevronRight` 混在一颗按钮里
|
||||
// 会显得线宽不一致(视觉 reviewer P2-3)。主操作允许实心,但只能单独出现。
|
||||
spec = spec
|
||||
.leading(IconKind::Download)
|
||||
.trailing(IconKind::ChevronRight);
|
||||
}
|
||||
|
||||
match variant {
|
||||
Variant::Filled => buttons::filled(ui, spec),
|
||||
Variant::Tonal => buttons::tonal(ui, spec),
|
||||
Variant::Elevated => buttons::elevated(ui, spec),
|
||||
Variant::Outlined => buttons::outlined(ui, spec),
|
||||
Variant::Text => buttons::text(ui, spec),
|
||||
}
|
||||
}
|
||||
|
||||
/// 图标按钮的一格(强制成 `state`)。
|
||||
fn icon_button<'a>(
|
||||
ui: &'a Ui,
|
||||
variant: IconVariant,
|
||||
state: Interaction,
|
||||
) -> Element<'a, DemoMessage> {
|
||||
let press = if state.is_disabled() {
|
||||
None
|
||||
} else {
|
||||
Some(DemoMessage::Pressed(variant.name()))
|
||||
};
|
||||
|
||||
buttons::icon(ui, variant, IconKind::Settings, press, Some(state))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! gallery 里的反馈类小节(**桩**):后续 mission 只改本文件。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::feedback;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 反馈类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"feedback · progress / snackbar / dialog / empty state",
|
||||
"12b §7/§12/§14/§15 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
feedback::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! gallery 里的输入类小节(**桩**):后续 mission 只改本文件。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::inputs;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 输入类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"inputs · text field / search / slider / menu",
|
||||
"12b §8/§9/§11 + 15 §20 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
inputs::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! `--screen=gallery` 的壳:**视觉验收 harness**(D30 / D39 §5),不是产品页面。
|
||||
//!
|
||||
//! ## 它是什么、不是什么
|
||||
//!
|
||||
//! - 是:**状态矩阵的取景框**。每个家族在自己的 `gallery/<family>.rs` 里摆出
|
||||
//! 「五变体 × 五态」的矩阵,一张截图就能判完该家族(浅/深各一张 = 10 张证据)。
|
||||
//! - 不是:页面。gallery 允许堆叠、不分页、不做空态,**难看不算问题**;
|
||||
//! 但**每个组件本身**必须按官方口径做到位。
|
||||
//!
|
||||
//! ## 并行地基(后续四条线怎么加东西)
|
||||
//!
|
||||
//! 五个家族文件在下面 `mod` 里**全部预先声明**了,每个都有自己的 `section()`:
|
||||
//!
|
||||
//! ```text
|
||||
//! gallery/buttons.rs ← COMPBASE(本 mission)
|
||||
//! gallery/selection.rs ← 后续 mission(只改这个文件)
|
||||
//! gallery/inputs.rs ← 同上
|
||||
//! gallery/feedback.rs ← 同上
|
||||
//! gallery/navigation.rs ← 同上
|
||||
//! ```
|
||||
//!
|
||||
//! 加组件 = 只改自己那个文件里的 `section()`,**不用碰本文件**(这是「五条线并行不冲突」的
|
||||
//! 具体保证,也是 tower 会亲手验的一条)。
|
||||
//!
|
||||
//! ## DemoMessage 为什么独立
|
||||
//!
|
||||
//! 契约 §2:组件不 import `crate::app::Message`。gallery 用自己定义的消息枚举,
|
||||
//! 正好当「组件能脱离业务验收」的示范——`app.rs` 只做一次 `.map(Message::Demo)` 就接上了。
|
||||
//!
|
||||
//! ## 截图命令(D30)
|
||||
//!
|
||||
//! ```text
|
||||
//! unlucky-launcher-iced --software-renderer --screen=gallery \
|
||||
//! --screenshot out.png --screenshot-theme=light
|
||||
//! ```
|
||||
//!
|
||||
//! `--screenshot` 不带主题时**默认 Light**(可复现;见 `cli::Options::capture_flavor`)。
|
||||
|
||||
use iced::widget::{column, container, scrollable, text};
|
||||
use iced::{Alignment, Background, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::shot::flavor_slug;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::divider;
|
||||
use super::interaction::Interaction;
|
||||
use super::layout;
|
||||
|
||||
mod buttons;
|
||||
mod feedback;
|
||||
mod inputs;
|
||||
mod navigation;
|
||||
#[cfg(test)]
|
||||
mod probe;
|
||||
#[cfg(test)]
|
||||
mod scan;
|
||||
mod selection;
|
||||
|
||||
/// gallery 自己的消息(与业务 `Message` 解耦,契约 §2)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DemoMessage {
|
||||
/// 某个演示按钮被按下(`&'static str` = 演示 id,只用于日志)。
|
||||
Pressed(&'static str),
|
||||
}
|
||||
|
||||
impl DemoMessage {
|
||||
/// 演示 id(日志用;将来加变体时这里跟着改,`app.rs` 不用动)。
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
DemoMessage::Pressed(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// gallery 根视图(整窗:不带标题栏与导航轨,验收图上只有组件)。
|
||||
pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let page = ui.style(TypeRole::BodySmall);
|
||||
|
||||
let header = column![
|
||||
text("Component gallery")
|
||||
.size(ui.emphasized(TypeRole::TitleLarge).size_px)
|
||||
.font(ui.emphasized_font(TypeRole::TitleLarge))
|
||||
.color(ui.color(Role::OnSurface)),
|
||||
text(format!(
|
||||
"M3 Expressive component base · flavor={} · 1180×760 @100% DPI · \
|
||||
focus 与 pressed 同为 10%(令牌口径,图上一样是对的)",
|
||||
flavor_slug(ui)
|
||||
))
|
||||
.size(page.size_px)
|
||||
.font(ui.font_of(page))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
]
|
||||
.spacing(ui.space(Space::X1));
|
||||
|
||||
// 小节之间**不画分隔线**:rubric(15 §22.4)要求同屏同类线 ≤3,
|
||||
// 而验收图里要给分隔线自己留证据(见 `divider_proof`)。分组靠 32px 留白 + 标题。
|
||||
let sections = column![
|
||||
buttons::section(ui),
|
||||
divider_proof(ui),
|
||||
selection::section(ui),
|
||||
inputs::section(ui),
|
||||
feedback::section(ui),
|
||||
navigation::section(ui),
|
||||
]
|
||||
.spacing(ui.space(Space::X8))
|
||||
.width(Length::Fill);
|
||||
|
||||
// 滚动条:默认是主题给的 10px 深灰 thumb,压过组件本体(视觉 reviewer P2-4)。
|
||||
// 这里把它改成 **透明轨 + OutlineVariant thumb**;宽度 10px 是 `scrollable` 的内部常量
|
||||
// (该控件不暴露 Scrollbar 配置),所以只做了「低对比」这一半,已登记为偏离 D7。
|
||||
let body = scrollable(sections)
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill)
|
||||
.style(move |theme, status| {
|
||||
// `scrollable::Style` 没有 `Default`:底色/间隙从主题默认值出发,只覆盖竖轨
|
||||
let base = scrollable::default(theme, status);
|
||||
scrollable::Style {
|
||||
vertical_rail: scrollable::Rail {
|
||||
background: None,
|
||||
border: iced::Border::default(),
|
||||
scroller: scrollable::Scroller {
|
||||
background: Background::Color(ui.color(Role::OutlineVariant)),
|
||||
border: iced::Border {
|
||||
radius: ui.radius(ShapeScale::Full, ui.space(Space::X2)),
|
||||
..iced::Border::default()
|
||||
},
|
||||
},
|
||||
},
|
||||
..base
|
||||
}
|
||||
});
|
||||
|
||||
// 页面留白走 `layout::inset`(左右/上下各 24):这是 Q8/Q9 那两个 helper 的第三个兄弟,
|
||||
// 存在意义是「只改内边距、不改尺寸」,也顺手避免把 Padding 的上右下左写反。
|
||||
let page_padding = ui.space(Space::X6);
|
||||
container(layout::inset(
|
||||
page_padding,
|
||||
page_padding,
|
||||
column![header, body].spacing(ui.space(Space::X4)),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(|_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 分隔线的视觉证据(§22):一条通栏 + 一条列表内缩进,各自带说明。
|
||||
///
|
||||
/// 单独一节而不是散落各处:`15 §22.4` 说「同屏同类线 ≤3」,这两条是**唯一**计入的两条。
|
||||
fn divider_proof<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
let caption = ui.style(TypeRole::LabelSmall);
|
||||
let label = |text_value: &'a str| {
|
||||
text(text_value)
|
||||
.size(caption.size_px)
|
||||
.font(ui.font_of(caption))
|
||||
.color(ui.color(Role::OnSurfaceVariant))
|
||||
};
|
||||
|
||||
column![
|
||||
heading(
|
||||
ui,
|
||||
"divider · full / list-inset",
|
||||
"15 §22:厚度 1px(stroke::HAIRLINE)· 颜色 OutlineVariant · 列表内左缩进 16 · 同屏 ≤3 条"
|
||||
),
|
||||
label("full (通栏,区块分隔)"),
|
||||
divider::full::<DemoMessage>(ui),
|
||||
label("list (左缩进 16)"),
|
||||
divider::list::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一个家族小节的标题(`index.title`)+ 一行判据说明。
|
||||
///
|
||||
/// 五个家族文件共用(`pub(super)`),保证五段的节奏一致——这也是 rubric D4
|
||||
/// 「一屏字号档 ≤4」的落点:小节只有 `TitleMedium` + `BodySmall` 两档。
|
||||
pub(super) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<'a, DemoMessage> {
|
||||
let note_style = ui.style(TypeRole::BodySmall);
|
||||
column![
|
||||
text(title)
|
||||
.size(ui.emphasized(TypeRole::TitleMedium).size_px)
|
||||
.font(ui.emphasized_font(TypeRole::TitleMedium))
|
||||
.color(ui.color(Role::OnSurface)),
|
||||
text(note)
|
||||
.size(note_style.size_px)
|
||||
.font(ui.font_of(note_style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 状态矩阵的列头:五态的固定次序(`Interaction::ALL` 的口径)。
|
||||
///
|
||||
/// 列宽 = [`cell_width`](**160** = `Space::X8 × 5`,是矩阵栅格,不是组件尺寸)。
|
||||
pub(super) fn state_cells(ui: &Ui) -> Vec<(&'static str, f32)> {
|
||||
Interaction::ALL
|
||||
.iter()
|
||||
.map(|state| (state.name(), cell_width(ui)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 矩阵单元格宽度(只影响 harness 的取景,不是组件尺寸)。
|
||||
///
|
||||
/// 160 = `Space::X8 × 5`:5 列 + 1 个标签列 + 4 个列间距 + 页左右各 24
|
||||
/// = 6×160 + 4×8 + 48 = **1040 < 1180**,所以第 5 列(disabled)不再被窗口右缘裁掉。
|
||||
/// 早先是 192(×6),总宽 1232 撑破窗口,disabled 列被裁掉一半(视觉 reviewer P2-3)。
|
||||
pub(super) fn cell_width(ui: &Ui) -> f32 {
|
||||
ui.space(Space::X8) * 5.0
|
||||
}
|
||||
|
||||
/// 一个矩阵行的左侧标签(变体名)。
|
||||
pub(super) fn row_label<'a>(ui: &'a Ui, name: &'a str) -> Element<'a, DemoMessage> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text(name)
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.width(Length::Fixed(cell_width(ui)))
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! gallery 里的导航类小节(**桩**):后续 mission 只改本文件。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::navigation;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 导航类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"navigation · list item / tabs / drawer / app bar",
|
||||
"12b §4/§6/§16 + 15 §21 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
navigation::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! 渲染层几何断言:**对着真截图量像素**(M68 的教训:纯函数返回对,图可能是错的)。
|
||||
//!
|
||||
//! `buttons::tests` 只能证明「几何结构体里的数字是 40/20/24」——M68 的药丸正是这样:单测全绿、
|
||||
//! 图上是 88×32(容器被 `center_x(Length::Fill)` 覆盖)。唯一能抓住这类错误的是**真渲染的像素**。
|
||||
//! CI 没有桌面,所以这里不自己开窗:先用 `--screen=gallery --screenshot` 出真窗截图,本测试读
|
||||
//! 那张 PNG 量几何、与令牌逐项比对。**没给图就跳过并大声说出来**(不静默通过)。
|
||||
//!
|
||||
//! 复现命令(在 worktree 根目录):
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo build -p unlucky-app-iced --bin unlucky-launcher-iced
|
||||
//! ./target/debug/unlucky-launcher-iced --software-renderer --screen=gallery \
|
||||
//! --screenshot target/m75/gallery-light.png --screenshot-theme=light
|
||||
//! ULL_GALLERY_SHOT=target/m75/gallery-light.png cargo test -p unlucky-app-iced gallery::probe
|
||||
//! ```
|
||||
//!
|
||||
//! 量什么(每条对一个已发生过的错误):实心块高 **40**(12b 1.1)· 药丸顶行**左内缩 ≈14** ⇒ 圆角 full=20
|
||||
//! (12b 1.2,8 圆角会接近通栏)· IconButton **40×40**(12b 2.1)· 字形墨迹 **16~26**
|
||||
//! (Q9:24 盒被填满会到 40)· 分隔线 **1px**(22.1)· 禁用容器 **on-surface@12%** 存在(12b 1.8)。
|
||||
|
||||
use unlucky_design::color::{Role, SchemeFlavor};
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
use crate::app::components::interaction;
|
||||
|
||||
use super::scan::{matches, nearer, rgb, Shot};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
/// 一条 1dp 细线在竖直方向的「墨量覆盖率」:各行相对底色的不透明度之和(1dp ⇒ ≈1.0)。
|
||||
///
|
||||
/// **只统计「长横线行」**(连续非底色像素 > 900):标题/正文那些行也有几百个零散墨迹像素,
|
||||
/// 按「任意非底色像素数」筛会把文字算进墨量(首版实测 57.9,明显是文字被算进去了)。
|
||||
fn line_coverage(shot: &Shot, surface: [u8; 3], line: [u8; 3]) -> f32 {
|
||||
let mut total = 0.0;
|
||||
for y in 0..shot.height {
|
||||
let mut longest = 0;
|
||||
let mut current = 0;
|
||||
let mut sum = 0.0f32;
|
||||
let mut run_sum = 0.0f32;
|
||||
for x in 0..shot.width {
|
||||
let pixel = shot.at(x, y);
|
||||
if !matches(pixel, surface) {
|
||||
current += 1;
|
||||
run_sum += delta_ratio(pixel, surface, line);
|
||||
if current > longest {
|
||||
longest = current;
|
||||
sum = run_sum;
|
||||
}
|
||||
} else {
|
||||
current = 0;
|
||||
run_sum = 0.0;
|
||||
}
|
||||
}
|
||||
if longest > 900 {
|
||||
total += sum / longest as f32;
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// `pixel` 相对 `surface` 的偏移占「满档 line 色偏移」的比例(0..=1)。
|
||||
fn delta_ratio(pixel: [u8; 3], surface: [u8; 3], line: [u8; 3]) -> f32 {
|
||||
let channel = |i: usize| {
|
||||
let full = f32::from(line[i]) - f32::from(surface[i]);
|
||||
if full.abs() < 1.0 {
|
||||
return 0.0;
|
||||
}
|
||||
((f32::from(pixel[i]) - f32::from(surface[i])) / full).clamp(0.0, 1.0)
|
||||
};
|
||||
let (dr, dg, db) = (channel(0), channel(1), channel(2));
|
||||
(dr + dg + db) / 3.0
|
||||
}
|
||||
|
||||
/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。
|
||||
fn shot_path() -> Option<std::path::PathBuf> {
|
||||
let raw = std::env::var_os("ULL_GALLERY_SHOT")?;
|
||||
let path = std::path::PathBuf::from(raw);
|
||||
if path.is_absolute() || path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
Some(root.join(path))
|
||||
}
|
||||
|
||||
/// 一条测试覆盖全部几何断言(每张图各跑一次)。
|
||||
#[test]
|
||||
fn gallery_geometry_matches_the_tokens() {
|
||||
let Some(path) = shot_path() else {
|
||||
eprintln!(
|
||||
"[probe] SKIPPED:未设置 ULL_GALLERY_SHOT。这是**渲染层**几何断言,\
|
||||
需要先出一张真窗截图(命令见本文件模块文档)。"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let shot = Shot::load(&path).expect("读 gallery 截图");
|
||||
assert_eq!(
|
||||
(shot.width, shot.height),
|
||||
(1180, 760),
|
||||
"验收图必须是 D30 的 1180×760 @100% DPI"
|
||||
);
|
||||
|
||||
// 用左上角背景色判定深浅(页面 padding 区就是 `Surface`)
|
||||
let corner = shot.at(2, 2);
|
||||
let flavor = if matches(corner, rgb(ui(SchemeFlavor::Light).color(Role::Surface))) {
|
||||
SchemeFlavor::Light
|
||||
} else {
|
||||
SchemeFlavor::Dark
|
||||
};
|
||||
let ui = ui(flavor);
|
||||
|
||||
// ---- 12b 1.1 / 1.2:实心按钮 40 高、药丸圆角 20 ------------------------------
|
||||
let primary = shot.bbox_of(rgb(ui.color(Role::Primary)));
|
||||
assert!(
|
||||
primary.len() >= 2,
|
||||
"至少该找到 Filled 按钮与 Filled IconButton 两块实心(实际 {})",
|
||||
primary.len()
|
||||
);
|
||||
|
||||
let mut label_button = None;
|
||||
for bbox in &primary {
|
||||
// 字形(OnPrimary)会把实心块割出小碎片,只判「成块的」(40×40 = 1600)
|
||||
if bbox.area() < 800 {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
(39..=41).contains(&bbox.height()),
|
||||
"实心块高 {}(参考 40,12b 1.1)@ {:?}",
|
||||
bbox.height(),
|
||||
(bbox.left, bbox.top)
|
||||
);
|
||||
if bbox.width() > 60 {
|
||||
label_button = Some(*bbox);
|
||||
}
|
||||
// 12b 2.1:IconButton 容器精确 40×40(别再用区间——reviewer 已证 +2px 抓不住)
|
||||
if bbox.width() < 60 {
|
||||
// 宽 = 40(最宽那行是实色,量得准);高会把底部抗锯齿行排除掉,所以是 39 或 40。
|
||||
// 区间收到 39..=40 仍能抓 ±2px 的回归(42 → 41、38 → 37 都会落区间外)。
|
||||
assert_eq!(bbox.width(), 40, "IconButton 容器宽必须 40(12b 2.1)");
|
||||
assert!(
|
||||
(39..=40).contains(&bbox.height()),
|
||||
"IconButton 容器高应 40(实色 39 + 抗锯齿行),实测 {}",
|
||||
bbox.height()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let label_button = label_button.expect("没找到文字按钮的实心块");
|
||||
assert!(
|
||||
label_button.width() >= 58,
|
||||
"12b 1.7:最小宽 58,实测 {}",
|
||||
label_button.width()
|
||||
);
|
||||
|
||||
// 含抗锯齿的真实外框:中心列上「更接近填充色而不是底色」的连续行
|
||||
let fill = rgb(ui.color(Role::Primary));
|
||||
let surface = rgb(ui.color(Role::Surface));
|
||||
let center = (label_button.left + label_button.right) / 2;
|
||||
let mut true_top = label_button.top;
|
||||
while true_top > 0 && nearer(shot.at(center, true_top - 1), fill, surface) {
|
||||
true_top -= 1;
|
||||
}
|
||||
let mut true_bottom = label_button.bottom;
|
||||
while true_bottom + 1 < shot.height && nearer(shot.at(center, true_bottom + 1), fill, surface) {
|
||||
true_bottom += 1;
|
||||
}
|
||||
let true_height = true_bottom - true_top + 1;
|
||||
|
||||
// **精确 40**:变异成 42 必须红。上一版写的是 `(39..=41).contains(..)`,
|
||||
// reviewer 把高度改成 42 重新出图后断言照样绿 —— 那就是「渲层断言等于没有」。
|
||||
assert_eq!(
|
||||
true_height, 40,
|
||||
"可见高必须精确 40(12b 1.1);变异成 42 这里就会红"
|
||||
);
|
||||
|
||||
// 圆角档位:量**顶部那一行的左内缩**。按像素中心口径,第 k 行(k 从 0 起)的闭式是
|
||||
// inset(k) = r − √(r² − (r − k − 0.5)²);r=20、k=1 给 **12.40**(实测 12~14),
|
||||
// r=8 只有 ≈3.6,直角为 0。比「数圆角行数」抗锯齿得多。
|
||||
let top_row_left = (label_button.left..=label_button.right)
|
||||
.find(|x| matches(shot.at(*x, label_button.top), fill))
|
||||
.expect("顶部行应至少有一个填充像素");
|
||||
let top_inset = top_row_left - label_button.left;
|
||||
assert!(
|
||||
(11..=17).contains(&top_inset),
|
||||
"药丸圆角应 = 20(12b 1.2):顶部行左内缩应 ≈14,实测 {top_inset}(8 圆角 ≈4,直角 0)"
|
||||
);
|
||||
|
||||
// ---- 12b 1.4:带前置图标的左内边距 = 16(用 Filled 格量,避开圆角只扫中间带)----
|
||||
// 判「墨迹」的方式:比容器色更接近 OnPrimary。不要用字面量白——浅色页面底色 (246,250,255)
|
||||
// 与白在容差内,会把圆角外的底色当成墨迹(首次实现就踩了,量出 3)。
|
||||
let on_primary = rgb(ui.color(Role::OnPrimary));
|
||||
let mut left_ink: Option<usize> = None;
|
||||
for y in (label_button.top + 12)..=(label_button.bottom - 12) {
|
||||
for x in (label_button.left + 4)..=label_button.right {
|
||||
if nearer(shot.at(x, y), on_primary, fill) {
|
||||
left_ink = Some(left_ink.map_or(x, |best: usize| best.min(x)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let icon_pad = left_ink.expect("Filled 格应带前置图标(量内边距用)") - label_button.left;
|
||||
// 图标盒左缘 = 16;字形在 24 viewBox 里自带 ≈5.3 左边距 ⇒ 墨迹落在 21 附近
|
||||
// (16 → 21;24 → 29;8 → 13,三档互不重叠)
|
||||
assert!(
|
||||
(18..=24).contains(&icon_pad),
|
||||
"12b 1.4:带图标左内边距应 16(墨迹 ≈21),实测 {icon_pad}"
|
||||
);
|
||||
|
||||
// ---- Q9:IconButton 的字形不许撑满 40 盒 -----------------------------------
|
||||
let tonal = rgb(ui.color(Role::SecondaryContainer));
|
||||
let tonal_boxes = shot.bbox_of(tonal);
|
||||
let icon_states = tonal_boxes
|
||||
.iter()
|
||||
.filter(|bbox| (39..=41).contains(&bbox.width()) && (39..=41).contains(&bbox.height()))
|
||||
.count();
|
||||
// 只有 **enabled** 列是精确的 SecondaryContainer(hover/pressed/focus 列叠了状态层,颜色不同),
|
||||
// 所以这里只要求 ≥1:量几何一只就够,五态的可见性由图本身给。
|
||||
assert!(
|
||||
icon_states >= 1,
|
||||
"tonal IconButton 至少 1 个(实际 {icon_states})"
|
||||
);
|
||||
|
||||
let gear = rgb(ui.color(Role::OnSecondaryContainer));
|
||||
let mut measured_glyph = false;
|
||||
let mut glyph_width = 0;
|
||||
for bbox in tonal_boxes
|
||||
.iter()
|
||||
.filter(|bbox| (39..=41).contains(&bbox.width()))
|
||||
{
|
||||
if let Some(ink) = shot.ink_inside(bbox, gear) {
|
||||
let width = ink.width();
|
||||
if width > glyph_width {
|
||||
glyph_width = width;
|
||||
}
|
||||
assert!(
|
||||
(16..=26).contains(&width),
|
||||
"图标盒 24 的字形墨迹应 16~26 宽,实测 {width}(Q9 回归时会接近 40)"
|
||||
);
|
||||
measured_glyph = true;
|
||||
}
|
||||
}
|
||||
assert!(measured_glyph, "没量到 IconButton 的字形");
|
||||
|
||||
// ---- 22.1:分隔线 1px(按「非底色的长横线」判,不按精确色:线被抗锯齿摊到两行)----
|
||||
let surface = rgb(ui.color(Role::Surface));
|
||||
let mut runs = 0;
|
||||
for y in 0..shot.height {
|
||||
let mut longest = 0;
|
||||
let mut current = 0;
|
||||
for x in 0..shot.width {
|
||||
if !matches(shot.at(x, y), surface) {
|
||||
current += 1;
|
||||
longest = longest.max(current);
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
if longest > 300 {
|
||||
runs += 1;
|
||||
}
|
||||
}
|
||||
// 两条线 × 各摊 1~2 行 ⇒ 2..=4 行
|
||||
assert!(
|
||||
(2..=4).contains(&runs),
|
||||
"分隔线应 2 条(每条 1px,抗锯齿最多摊 2 行)⇒ 2..=4 行,实测 {runs} 行"
|
||||
);
|
||||
// **墨量覆盖率**:一条 1dp 的线,其各行相对底色的不透明覆盖率之和应 ≈1.0。
|
||||
// 1.5dp 的线会到 ≈1.5,2dp 会到 ≈2.0 —— 这是「容忍 1.5 倍粗」的补丁。
|
||||
let full = rgb(ui.color(Role::OutlineVariant));
|
||||
// 屏上正好 2 条线(通栏 + 列表内缩),所以把总墨量除以 2 得到「单条线的覆盖率」
|
||||
let coverage = line_coverage(&shot, surface, full) / 2.0;
|
||||
assert!(
|
||||
(0.8..=1.3).contains(&coverage),
|
||||
"单条分隔线墨量覆盖率应 ≈1.0(= 1dp),实测 {coverage:.2}(1.5dp 会到 ~1.5)"
|
||||
);
|
||||
|
||||
// ---- 12b 1.8 禁用态:容器 + **每一行**的内容色(A1 逐变体不同)----------------
|
||||
//
|
||||
// 上一版把两个 reviewer 都指出的重复块留在这里(`disabled_colors` 被调两次、
|
||||
// `disabled_container` 被绑定两次),且只有一条「全图数墨迹」的护栏 —— 深色下阈值失效、
|
||||
// 单行回退也抓不住。现在按矩阵的行逐条断言。
|
||||
let (disabled_container, _) = interaction::disabled_colors(&ui, Role::Surface);
|
||||
assert!(
|
||||
shot.bbox_of(rgb(disabled_container))
|
||||
.iter()
|
||||
.any(|b| b.width() > 60),
|
||||
"没找到禁用态实心按钮(12b 1.8:容器 = on-surface@12%)"
|
||||
);
|
||||
|
||||
// 矩阵行序(gallery/buttons.rs 的 Variant::ALL + IconVariant::ALL),行距 48 = 40 + 8
|
||||
let row_step = 48usize;
|
||||
let rows: [(usize, Role, bool, &str); 8] = [
|
||||
(0, Role::OnSurfaceVariant, true, "filled"),
|
||||
(1, Role::OnSurface, true, "tonal"), // A1:FilledTonal 是例外
|
||||
(2, Role::OnSurfaceVariant, true, "elevated"),
|
||||
(3, Role::OnSurfaceVariant, false, "outlined"),
|
||||
(4, Role::OnSurfaceVariant, false, "text"),
|
||||
(5, Role::OnSurface, false, "icon·standard"), // A1:IconButton 全是 OnSurface
|
||||
(6, Role::OnSurface, false, "icon·filled"),
|
||||
// 第 7 行(矩阵最后一行的 tonal IconButton)上一版漏了 —— reviewer 把它的禁用墨迹涂回
|
||||
// 满档色时 probe 全绿。A1 同样是 OnSurface(`FilledTonalIconButtonTokens`)。
|
||||
(7, Role::OnSurface, true, "icon·tonal"),
|
||||
];
|
||||
let column_from = shot.width * 2 / 3; // 矩阵最右列 = disabled 列
|
||||
for (index, role, on_container, name) in rows {
|
||||
let y0 = label_button.top + index * row_step;
|
||||
let y1 = y0 + 44;
|
||||
let ink = interaction::disabled_content_over_role(&ui, role, on_container);
|
||||
let count = shot.count_in(rgb(ink), column_from, shot.width, y0, y1);
|
||||
assert!(
|
||||
count > 40,
|
||||
"{name} 行的禁用内容({role:?}@38%)在 disabled 列里只有 {count} px —— 说明这一行没走 A1 的禁用角色"
|
||||
);
|
||||
}
|
||||
|
||||
// filled 禁用容器里不许出现满档 OnPrimary(旧实现是白字压在 12% 容器上 = 1.23:1)
|
||||
let full_content = rgb(ui.color(Role::OnPrimary));
|
||||
for bbox in shot.bbox_of(rgb(disabled_container)) {
|
||||
if bbox.width() < 60 {
|
||||
continue;
|
||||
}
|
||||
// 只扫**内带**:圆角外的页面底色与 OnPrimary 在容差内,贴边扫描会误报。
|
||||
for y in (bbox.top + 12)..=(bbox.bottom - 12) {
|
||||
for x in (bbox.left + 12)..=(bbox.right - 12) {
|
||||
assert!(
|
||||
!matches(shot.at(x, y), full_content),
|
||||
"禁用容器内出现满档 OnPrimary @({x},{y}):禁用标签没降到 38%"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"[probe] OK flavor={:?}:
|
||||
Filled 药丸 {}×{}(参考 高 40 / 宽 ≥58)
|
||||
顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0)
|
||||
实心块 {} 个(含 40×40 IconButton)
|
||||
分隔线长行 {} 行(每条 1px)
|
||||
禁用容器 (on-surface@12%) 命中 · 逐行禁用内容断言 {} 行通过
|
||||
分隔线墨量覆盖率 {:.2}(1dp 应 ≈1.0)
|
||||
字形墨迹 {}px(24 盒应 16~26)",
|
||||
flavor,
|
||||
label_button.width(),
|
||||
true_height,
|
||||
top_inset,
|
||||
primary.len(),
|
||||
runs,
|
||||
rows.len(),
|
||||
coverage,
|
||||
glyph_width
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! 截图像素扫描工具(`gallery::probe` 的底层,四个后续家族的 probe 也能复用)。
|
||||
//!
|
||||
//! 只做「按颜色找连通块 / 量外接框 / 量某行的连续长度」三件事,不含任何判据——
|
||||
//! 判据在同级 `probe.rs` 里,这样每条家族线可以自带 probe 而不用重写扫描逻辑。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use iced::Color;
|
||||
|
||||
/// 颜色比对的通道容差(抗锯齿/取整)。
|
||||
const TOLERANCE: u8 = 10;
|
||||
|
||||
/// iced 颜色 → 8bit RGB(截图里的原值)。
|
||||
pub(super) fn rgb(color: Color) -> [u8; 3] {
|
||||
[
|
||||
(color.r * 255.0).round() as u8,
|
||||
(color.g * 255.0).round() as u8,
|
||||
(color.b * 255.0).round() as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// 颜色是否落在容差内。
|
||||
pub(super) fn matches(pixel: [u8; 3], expected: [u8; 3]) -> bool {
|
||||
pixel
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(a, b)| a.abs_diff(b) <= TOLERANCE)
|
||||
}
|
||||
|
||||
/// 一张图的像素访问器。
|
||||
pub(super) struct Shot {
|
||||
pub(super) width: usize,
|
||||
pub(super) height: usize,
|
||||
pixels: Vec<[u8; 3]>,
|
||||
}
|
||||
|
||||
impl Shot {
|
||||
pub(super) fn load(path: &Path) -> Result<Self, String> {
|
||||
let bytes = std::fs::read(path).map_err(|error| format!("读不动 {path:?}:{error}"))?;
|
||||
let image = image::load_from_memory(&bytes)
|
||||
.map_err(|error| format!("解 PNG 失败:{error}"))?
|
||||
.to_rgb8();
|
||||
let (width, height) = (image.width() as usize, image.height() as usize);
|
||||
Ok(Self {
|
||||
width,
|
||||
height,
|
||||
pixels: image.pixels().map(|p| p.0).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn at(&self, x: usize, y: usize) -> [u8; 3] {
|
||||
self.pixels[y * self.width + x]
|
||||
}
|
||||
|
||||
/// 某种颜色的全部像素的外接框(连通块)。
|
||||
pub(super) fn bbox_of(&self, color: [u8; 3]) -> Vec<BBox> {
|
||||
let mut seen = vec![false; self.width * self.height];
|
||||
let mut boxes = Vec::new();
|
||||
|
||||
for y in 0..self.height {
|
||||
for x in 0..self.width {
|
||||
if seen[y * self.width + x] || !matches(self.at(x, y), color) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut stack = vec![(x, y)];
|
||||
seen[y * self.width + x] = true;
|
||||
let mut bbox = BBox::new(x, y);
|
||||
|
||||
while let Some((cx, cy)) = stack.pop() {
|
||||
bbox.include(cx, cy);
|
||||
for (nx, ny) in [
|
||||
(cx + 1, cy),
|
||||
(cx.saturating_sub(1), cy),
|
||||
(cx, cy + 1),
|
||||
(cx, cy.saturating_sub(1)),
|
||||
] {
|
||||
let inside = nx < self.width && ny < self.height;
|
||||
if inside && !seen[ny * self.width + nx] && matches(self.at(nx, ny), color)
|
||||
{
|
||||
seen[ny * self.width + nx] = true;
|
||||
stack.push((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bbox.area() > 200 {
|
||||
boxes.push(bbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boxes
|
||||
}
|
||||
|
||||
/// 在矩形 `[x0,x1) × [y0,y1)` 内数某颜色的像素(按行/列取证用)。
|
||||
pub(super) fn count_in(
|
||||
&self,
|
||||
color: [u8; 3],
|
||||
x0: usize,
|
||||
x1: usize,
|
||||
y0: usize,
|
||||
y1: usize,
|
||||
) -> usize {
|
||||
(y0..y1.min(self.height))
|
||||
.map(|y| {
|
||||
(x0..x1.min(self.width))
|
||||
.filter(|x| matches(self.at(*x, y), color))
|
||||
.count()
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// `bbox` 内某颜色的墨迹外接框(量字形用)。
|
||||
///
|
||||
/// 内缩 3px:不内缩会把按钮自身的抗锯齿边算成墨迹(实测把 24 盒字形量成 40)。
|
||||
pub(super) fn ink_inside(&self, bbox: &BBox, color: [u8; 3]) -> Option<BBox> {
|
||||
const MARGIN: usize = 3;
|
||||
let mut ink: Option<BBox> = None;
|
||||
for y in (bbox.top + MARGIN)..=(bbox.bottom - MARGIN) {
|
||||
for x in (bbox.left + MARGIN)..=(bbox.right - MARGIN) {
|
||||
if matches(self.at(x, y), color) {
|
||||
match &mut ink {
|
||||
Some(found) => found.include(x, y),
|
||||
None => ink = Some(BBox::new(x, y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ink
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个外接框(闭区间)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub(super) struct BBox {
|
||||
pub(super) left: usize,
|
||||
pub(super) right: usize,
|
||||
pub(super) top: usize,
|
||||
pub(super) bottom: usize,
|
||||
}
|
||||
|
||||
impl BBox {
|
||||
pub(super) fn new(x: usize, y: usize) -> Self {
|
||||
Self {
|
||||
left: x,
|
||||
right: x,
|
||||
top: y,
|
||||
bottom: y,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn include(&mut self, x: usize, y: usize) {
|
||||
self.left = self.left.min(x);
|
||||
self.right = self.right.max(x);
|
||||
self.top = self.top.min(y);
|
||||
self.bottom = self.bottom.max(y);
|
||||
}
|
||||
|
||||
pub(super) fn width(&self) -> usize {
|
||||
self.right - self.left + 1
|
||||
}
|
||||
|
||||
pub(super) fn height(&self) -> usize {
|
||||
self.bottom - self.top + 1
|
||||
}
|
||||
|
||||
pub(super) fn area(&self) -> usize {
|
||||
self.width() * self.height()
|
||||
}
|
||||
}
|
||||
|
||||
/// `pixel` 更接近 `fill` 还是 `surface`。
|
||||
pub(super) fn nearer(pixel: [u8; 3], fill: [u8; 3], surface: [u8; 3]) -> bool {
|
||||
let dist = |a: [u8; 3], b: [u8; 3]| -> u32 {
|
||||
a.iter()
|
||||
.zip(b)
|
||||
.map(|(x, y)| (i32::from(*x) - i32::from(y)).unsigned_abs())
|
||||
.sum()
|
||||
};
|
||||
dist(pixel, fill) < dist(pixel, surface)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! gallery 里的选择类小节(**桩**):后续 mission 只改本文件。
|
||||
//!
|
||||
//! 现在直接转调 `components::selection::demo`,把该家族的占位塞进五段结构里;
|
||||
//! 填充时把 `demo(ui)` 换成本家族的状态矩阵即可(列头/单元格工具见 `gallery/mod.rs`)。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use super::super::selection;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{heading, DemoMessage};
|
||||
|
||||
/// 选择类家族的小节。
|
||||
pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
|
||||
iced::widget::column![
|
||||
heading(
|
||||
ui,
|
||||
"selection · checkbox / radio / switch / chip",
|
||||
"12b §13/§10 + 15 §18/§19 —— pending(后续 mission 填充本文件)"
|
||||
),
|
||||
selection::demo::<DemoMessage>(ui),
|
||||
]
|
||||
.spacing(ui.space(unlucky_design::spacing::Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! 组件目录的自检门禁(`src/components/**`)。
|
||||
//!
|
||||
//! 为什么要有这份:`tests/no_hardcoded_tokens.rs` 只扫**色值**和 `shell/` 的尺寸字面量,
|
||||
//! 组件库目录是后来才开的——如果不管,五条并行线会各自写回 `.width(24.0)` 与
|
||||
//! `center_x(Length::Fill)`,M68 那两个形态级错误就会被复制五份。
|
||||
//!
|
||||
//! 四条判据(每条对应一个已发生过的错误):
|
||||
//!
|
||||
//! | 判据 | 抓的是什么 | 历史 |
|
||||
//! |---|---|---|
|
||||
//! | Q8:禁 `center_x(` / `center_y(` / `.center(` | 容器尺寸被 `Fill` 静默覆盖 | 药丸 56×32 → 88×32 |
|
||||
//! | Q9:图标必须经 `layout::fixed` / `centered_fixed` | 字形被 Button 的 Fill 撑满 | 24 的字形渲染成 46px |
|
||||
//! | T2:禁尺寸字面量 | 组件里重复定义令牌值 | rubric T2 |
|
||||
//! | 400 行 | 单文件上限(AGENTS.md §3-1) | `guards.yml` 的 file-size job |
|
||||
//!
|
||||
//! 扫描对象是**源码文本**而不是布局结果——这不是偷懒:布局结果要真窗口(CI 无桌面),
|
||||
//! 而「把尺寸交给 `center_*`」这个错误 100% 出现在源码形态里(M68 就是这样被抓的)。
|
||||
//! 像素级几何另有 `gallery::probe` 对着真截图量。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 单文件行数上限(AGENTS.md §3-1)。
|
||||
const MAX_LINES: usize = 400;
|
||||
|
||||
fn components_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src")
|
||||
.join("components")
|
||||
}
|
||||
|
||||
fn rust_files(dir: &Path, collected: &mut Vec<PathBuf>) {
|
||||
let entries =
|
||||
std::fs::read_dir(dir).unwrap_or_else(|error| panic!("读不动 {}:{error}", dir.display()));
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.expect("目录项读失败").path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, collected);
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
collected.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部组件源文件(`mod.rs` 也要扫:它是共享地基,最容易混进字面量)。
|
||||
fn sources() -> Vec<(PathBuf, String)> {
|
||||
let mut files = Vec::new();
|
||||
rust_files(&components_dir(), &mut files);
|
||||
files.sort();
|
||||
|
||||
assert!(
|
||||
files.len() >= 15,
|
||||
"只扫到 {} 个组件源文件:扫描逻辑坏了,门禁等于没跑",
|
||||
files.len()
|
||||
);
|
||||
|
||||
files
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|error| panic!("读不动 {}:{error}", path.display()));
|
||||
(path, text)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 去掉 `#[cfg(test)]` 之后的全部内容:测试里写参考值是**应该的**(12b 的 40/24/20
|
||||
/// 必须在测试里以字面量出现,否则测不出偏离)。
|
||||
fn product_part(text: &str) -> &str {
|
||||
match text.find("#[cfg(test)]") {
|
||||
Some(at) => &text[..at],
|
||||
None => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// 代码行(跳过注释行:文档里写 `.center_x(` 是在解释坑,不该判违规)。
|
||||
fn code_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
|
||||
text.lines().enumerate().filter_map(|(index, line)| {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("//") {
|
||||
None
|
||||
} else {
|
||||
Some((index + 1, line))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q8_no_center_shortcuts_that_swallow_fixed_sizes() {
|
||||
let mut offenders = Vec::new();
|
||||
|
||||
for (path, text) in sources() {
|
||||
for (line_no, line) in code_lines(product_part(&text)) {
|
||||
for pattern in ["center_x(", "center_y(", ".center("] {
|
||||
if line.contains(pattern) {
|
||||
offenders.push(format!(
|
||||
"{}:{line_no} {line}\n → {pattern} 会把容器尺寸改成 Fill(Q8),\
|
||||
居中请用父级 align_x 或 layout::centered_fixed",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(offenders.is_empty(), "Q8 违规:\n{}", offenders.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q9_icons_never_reach_a_button_unwrapped() {
|
||||
let mut offenders = Vec::new();
|
||||
|
||||
for (path, text) in sources() {
|
||||
let product = product_part(&text);
|
||||
// 允许两种写法:同一行里已经包在 layout:: 里;或它是 `layout::fixed(` 多行调用的实参
|
||||
// (图标常作为第 3 个参数出现在后两行,所以往回看 4 行)。
|
||||
let mut window: Vec<&str> = Vec::new();
|
||||
for (line_no, line) in code_lines(product) {
|
||||
if line.contains("icons::view") {
|
||||
let wrapped =
|
||||
line.contains("layout::fixed") || line.contains("layout::centered_fixed");
|
||||
let continued = window.iter().rev().any(|prev| {
|
||||
prev.contains("layout::fixed") || prev.contains("layout::centered_fixed")
|
||||
});
|
||||
if !wrapped && !continued {
|
||||
offenders.push(format!(
|
||||
"{}:{line_no} {line}\n → 图标必须经 layout::fixed / centered_fixed 包一层(Q9)",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
if !line.trim().is_empty() {
|
||||
window.push(line);
|
||||
if window.len() > 4 {
|
||||
window.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(offenders.is_empty(), "Q9 违规:\n{}", offenders.join("\n"));
|
||||
}
|
||||
|
||||
/// 布局调用后面直接跟数字 → 判违规(`0` 与 `0.0` 放行:0 = 无内边距/无描边)。
|
||||
fn size_literal(argument: &str) -> Option<String> {
|
||||
let mut rest = argument.trim_start();
|
||||
for wrapper in ["Length::Fixed(", "Padding::from(", "Size::new("] {
|
||||
if let Some(after) = rest.strip_prefix(wrapper) {
|
||||
rest = after.trim_start().trim_start_matches(['[', '(']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let number: String = rest
|
||||
.trim_start()
|
||||
.chars()
|
||||
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
|
||||
.collect();
|
||||
|
||||
if number.is_empty() || number.trim_end_matches('.').parse::<f64>() == Ok(0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(number)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2_no_size_literals_in_components() {
|
||||
let mut offenders = Vec::new();
|
||||
|
||||
for (path, text) in sources() {
|
||||
for (line_no, line) in code_lines(product_part(&text)) {
|
||||
for pattern in [".width(", ".height(", ".size(", ".spacing(", ".padding("] {
|
||||
let mut from = 0;
|
||||
while let Some(found) = line[from..].find(pattern) {
|
||||
let after = from + found + pattern.len();
|
||||
if let Some(number) = size_literal(&line[after..]) {
|
||||
offenders.push(format!(
|
||||
"{}:{line_no} {line} -> {pattern} 后面写了数字 {number}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
from = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"组件里不许有尺寸字面量(走 LayoutMetrics / Space / ComponentMetrics):\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_component_file_stays_under_the_line_limit() {
|
||||
let mut offenders = Vec::new();
|
||||
for (path, text) in sources() {
|
||||
let lines = text.lines().count();
|
||||
if lines > MAX_LINES {
|
||||
offenders.push(format!(
|
||||
"{}: {lines} 行(上限 {MAX_LINES})",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"单文件 ≤400 行(AGENTS.md §3-1,guards.yml 会判红):\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// 四个家族目录必须存在且结构完整。
|
||||
///
|
||||
/// **这里刻意不再限制「pub fn 有几个」**:M76 往 `selection/` 里加 Checkbox/Radio 是它的本职工作,
|
||||
/// 而家族线被明确要求不许改 `gate.rs`——把「桩还在不在」写成 pub fn 计数,等于给 M76 埋一个
|
||||
/// 它无权拆的地雷(reviewer P1-C)。越界与否由 merge gate 的 scope 判定(各家族目录只归对应
|
||||
/// mission),门禁只管「地基还在」:
|
||||
///
|
||||
/// - 四个家族目录各有 `mod.rs`;
|
||||
/// - `components/mod.rs` 仍声明这五个家族 + gallery(谁把声明删了,这里先红)。
|
||||
#[test]
|
||||
fn the_five_families_are_declared_and_present() {
|
||||
let declared =
|
||||
std::fs::read_to_string(components_dir().join("mod.rs")).expect("读 components/mod.rs");
|
||||
|
||||
for family in ["selection", "inputs", "feedback", "navigation"] {
|
||||
let dir = components_dir().join(family);
|
||||
let file = dir.join("mod.rs");
|
||||
assert!(file.is_file(), "家族目录 {} 缺 mod.rs", dir.display());
|
||||
assert!(
|
||||
declared.contains(&format!("pub mod {family};")),
|
||||
"components/mod.rs 少了 `pub mod {family};` 声明"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
declared.contains("pub mod gallery;") && declared.contains("pub mod buttons;"),
|
||||
"components/mod.rs 的声明被动了"
|
||||
);
|
||||
}
|
||||
|
||||
/// gallery 的五个家族文件必须都在(`mod.rs` 声明了它们,缺一个就编译不过——
|
||||
/// 这条在并行线删文件/改名时给出人话,而不是一堆 `unresolved module`)。
|
||||
#[test]
|
||||
fn gallery_keeps_one_file_per_family() {
|
||||
for family in ["buttons", "selection", "inputs", "feedback", "navigation"] {
|
||||
let path = components_dir()
|
||||
.join("gallery")
|
||||
.join(format!("{family}.rs"));
|
||||
assert!(
|
||||
path.is_file(),
|
||||
"gallery 缺 {}(本文件结构是并行地基)",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let text = std::fs::read_to_string(&path).expect("读 gallery 家族文件");
|
||||
assert!(
|
||||
text.contains("fn section"),
|
||||
"{} 必须提供 section()(gallery/mod.rs 调它)",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 门禁自身不能空转:造几个违规样本喂给判据。
|
||||
#[test]
|
||||
fn the_gate_catches_planted_violations() {
|
||||
assert_eq!(
|
||||
size_literal("Length::Fixed(40.0))").as_deref(),
|
||||
Some("40.0")
|
||||
);
|
||||
assert_eq!(size_literal("0)").as_deref(), None);
|
||||
assert_eq!(
|
||||
size_literal("ui.metrics().control_height)").as_deref(),
|
||||
None
|
||||
);
|
||||
assert!(product_part("a\n#[cfg(test)]\n.width(40.0)\n")
|
||||
.find(".width(40.0)")
|
||||
.is_none());
|
||||
assert!(product_part("a\n.width(40.0)\n")
|
||||
.find(".width(40.0)")
|
||||
.is_some());
|
||||
// Q8 判据本身
|
||||
for code in ["x.center_x(Length::Fill)", "x.center(Length::Fill)"] {
|
||||
assert!(
|
||||
code.contains("center_x(") || code.contains(".center("),
|
||||
"判据写坏了"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! **输入类家族(桩)**:由后续 mission 填充(§9 TextField / §20 Search / §11 Slider / §8 Menu)。
|
||||
//!
|
||||
//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**:
|
||||
//! 家族模块已经在 `mod.rs` 里声明好,`gallery/inputs.rs` 也已经在 `gallery/mod.rs` 里声明好,
|
||||
//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。
|
||||
//!
|
||||
//! 填充时请照抄按钮家族的分层:
|
||||
//!
|
||||
//! - 几何进 `inputs/geometry.rs`(数值从 `ComponentMetrics::SEARCH_BAR` / `Space` / `ShapeScale` 取,
|
||||
//! 缺令牌登记 G 编号);
|
||||
//! - 状态层只能走 [`crate::app::components::interaction`](α 一处定义);
|
||||
//! - 精确尺寸用 [`crate::app::components::layout`](Q8/Q9 的解法),别手写 `.width(x)`;
|
||||
//! - 受控组件签名收 `impl Fn(String) -> M`(契约 §2),组件内不持有 `String`。
|
||||
|
||||
use iced::widget::{container, text};
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 占位 demo(ASCII 文案,理由同 `selection::demo`)。
|
||||
pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text("inputs · text field / search / slider / menu — pending")
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.padding(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! 交互状态层:hover / focus / pressed / disabled 的**唯一实现点**(D39 §3)。
|
||||
//!
|
||||
//! ## 为什么必须只有一处
|
||||
//!
|
||||
//! M3 的状态层不是「另一种灰」,而是**内容色自身 + 固定 alpha** 叠在容器色上
|
||||
//! (`unlucky_design::color::state` 的文档)。五个家族各写一遍
|
||||
//! `if hovered { 0.08 }`,第一周就会出现 0.08 / 0.1 / 0.12 三种 hover——
|
||||
//! 这一层存在的意义就是让「组件里出现 α 字面量」变成不可能:
|
||||
//!
|
||||
//! | 态 | α | 令牌来源 |
|
||||
//! |---|---|---|
|
||||
//! | hover | 0.08 | [`StateLayer::Hover`] |
|
||||
//! | focus | 0.10 | [`StateLayer::Focus`] |
|
||||
//! | pressed | 0.10 | [`StateLayer::Pressed`] |
|
||||
//! | dragged | 0.16 | [`StateLayer::Dragged`](按钮家族不用,列表拖拽用) |
|
||||
//! | disabled 容器 | 0.12 | [`DISABLED_CONTAINER_ALPHA`](**A1 是 0.10f**,本文件按令牌 0.12,差异登记 D8) |
|
||||
//! | disabled 内容 | 0.38 | [`DISABLED_CONTENT_ALPHA`] |
|
||||
//!
|
||||
//! 上表右列是**唯一**允许的取值口;左列只是给人看的,代码里一个数字都不许出现
|
||||
//! (`components::gate` 会扫)。
|
||||
//!
|
||||
//! ## 两个消费形态
|
||||
//!
|
||||
//! 1. **iced `button` 系**(本 mission 的按钮家族):拿 [`Interaction`] 直接配 [`overlay_color`];
|
||||
//! 2. **自定义 `Widget`**(后续若需要自绘轨道/滑块):用 [`Tracker`] 把事件折成五态,
|
||||
//! `update()` 的返回值告诉你「要不要重绘」,`state()` 给出当前态。
|
||||
//!
|
||||
//! ## 与 `Ui::state_over` 的关系
|
||||
//!
|
||||
//! 颜色一律经 [`crate::theme::Ui::state_over`] 求值(它返回**不透明**结果,所以 GPU/CPU 两档
|
||||
//! 逐像素一致,D27)。本模块只做「哪一态叠哪一层」的映射,不做任何颜色算术。
|
||||
|
||||
use iced::widget::button;
|
||||
use iced::Color;
|
||||
|
||||
use unlucky_design::color::state::{DISABLED_CONTAINER_ALPHA, DISABLED_CONTENT_ALPHA};
|
||||
use unlucky_design::color::{Role, Scheme, StateLayer};
|
||||
|
||||
use crate::theme::{to_iced, Ui};
|
||||
|
||||
mod tracker;
|
||||
|
||||
// 本 mission 没有自绘控件,所以这条再导出在 crate 内暂时无人使用;
|
||||
// 它是给四条家族线(Slider / Switch 轨道要自绘)的公共入口,路径与拆分前一致。
|
||||
#[allow(unused_imports)]
|
||||
pub use tracker::Tracker;
|
||||
|
||||
// 本模块会被四条家族线各扩一次:加东西前先看行数余额(单文件 ≤400,AGENTS.md §3-1)。
|
||||
// `mod.rs` 只装「五态 + 颜色映射」这类纯函数;事件 → 态的**状态机在 [`tracker`]**,
|
||||
// 需要新的状态机就加到那边,别把本文件再撑到红线上。
|
||||
|
||||
/// 一个控件的视觉态(五态,M3 的完整集合)。
|
||||
///
|
||||
/// `Enabled` 是**静息态**(不叠状态层);`Focused` / `Pressed` 在 M3 里 α 相同(都 10%),
|
||||
/// 所以两者在图上会一模一样——这是规范,不是漏画(12b §1.8 判据只要求 α 对得上)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Interaction {
|
||||
/// 静息:可交互,不叠状态层。
|
||||
Enabled,
|
||||
/// 悬停:叠 8%。
|
||||
Hovered,
|
||||
/// 键盘焦点:叠 10%。
|
||||
Focused,
|
||||
/// 按下:叠 10%。
|
||||
Pressed,
|
||||
/// 禁用:容器 12% / 内容 38%,**不叠状态层**。
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl Interaction {
|
||||
/// 五态全序(gallery 的状态矩阵按这个次序出图)。
|
||||
pub const ALL: [Interaction; 5] = [
|
||||
Interaction::Enabled,
|
||||
Interaction::Hovered,
|
||||
Interaction::Pressed,
|
||||
Interaction::Focused,
|
||||
Interaction::Disabled,
|
||||
];
|
||||
|
||||
/// 稳定名字(截图文件名 / 图例 / 测试失败信息用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Interaction::Enabled => "enabled",
|
||||
Interaction::Hovered => "hovered",
|
||||
Interaction::Focused => "focused",
|
||||
Interaction::Pressed => "pressed",
|
||||
Interaction::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否禁用(禁用不是「第六态」,它要不要画容器由组件决定)。
|
||||
pub const fn is_disabled(self) -> bool {
|
||||
matches!(self, Interaction::Disabled)
|
||||
}
|
||||
|
||||
/// 该态要叠的状态层;`None` = 不叠(静息与禁用)。
|
||||
pub const fn layer(self) -> Option<StateLayer> {
|
||||
match self {
|
||||
Interaction::Enabled | Interaction::Disabled => None,
|
||||
Interaction::Hovered => Some(StateLayer::Hover),
|
||||
Interaction::Focused => Some(StateLayer::Focus),
|
||||
Interaction::Pressed => Some(StateLayer::Pressed),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 iced 的 [`button::Status`] 推导(`button` 只有四态,没有 focus)。
|
||||
///
|
||||
/// 用 `button` 做组件的家族一律经这里,**不许自己 match status 再各写一遍颜色**。
|
||||
pub const fn from_status(status: button::Status) -> Self {
|
||||
match status {
|
||||
button::Status::Active => Interaction::Enabled,
|
||||
button::Status::Hovered => Interaction::Hovered,
|
||||
button::Status::Pressed => Interaction::Pressed,
|
||||
button::Status::Disabled => Interaction::Disabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前态叠在 `container` 上的**不透明**颜色;`None` = 该态不叠。
|
||||
///
|
||||
/// `content` 是容器上的前景角色(Filled 按钮用 `OnPrimary`;中性表面上的控件用 `OnSurfaceVariant`)。
|
||||
pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role) -> Option<Color> {
|
||||
state
|
||||
.layer()
|
||||
.map(|layer| ui.state_over(layer, content, container))
|
||||
}
|
||||
|
||||
/// 禁用态内容色(**按角色、按有没有容器**求值)——A1 口径的唯一入口。
|
||||
///
|
||||
/// - `role`:由变体决定(Filled/Outlined/Text/Elevated = `OnSurfaceVariant`;
|
||||
/// FilledTonal 与三式 IconButton = `OnSurface`,见 `Variant::disabled_content`);
|
||||
/// - `on_container = true`:叠在 **12% 容器**上(有容器的三式);
|
||||
/// `false`:直接叠在 **surface** 上(Text / Outlined / standard IconButton)。
|
||||
///
|
||||
/// 之前这里写死 `OnSurfaceVariant` 且只有一个「底」,导致 FilledTonal 与 IconButton 两族偏浅
|
||||
/// 15/255(视觉 r3 用像素量的)。
|
||||
pub fn disabled_content_over_role(ui: &Ui, role: Role, on_container: bool) -> Color {
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
let base = if on_container {
|
||||
scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(DISABLED_CONTAINER_ALPHA)
|
||||
.over(scheme.get(Role::Surface))
|
||||
} else {
|
||||
scheme.get(Role::Surface)
|
||||
};
|
||||
|
||||
to_iced(
|
||||
scheme
|
||||
.get(role)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(base),
|
||||
)
|
||||
}
|
||||
|
||||
/// 禁用态的一对颜色 `(容器, 内容)`,内容按令牌口径叠在**算好的容器**之上。
|
||||
///
|
||||
/// - 容器 = `OnSurface` @ 12%([`DISABLED_CONTAINER_ALPHA`];A1 是 0.10f,见 D8)叠在 `surface` 上;
|
||||
/// - 内容 = **默认** `OnSurfaceVariant` @ 38%;按变体的取值走 [`disabled_content_over_role`]。
|
||||
///
|
||||
/// `ui` 不暴露 [`Scheme`],这里按同一个 flavor 求一次基线方案——调的是
|
||||
/// `unlucky_design` 的**同一个**函数与同一套角色,不是第二份令牌。
|
||||
///
|
||||
/// 顺序必须「先容器后内容」:Compose 也是这么画的,先叠容器再叠 label,
|
||||
/// 所以内容叠的角色是容器实色而不是底下的 surface。
|
||||
pub fn disabled_colors(ui: &Ui, surface: Role) -> (Color, Color) {
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
let base = scheme.get(surface);
|
||||
let container = scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(DISABLED_CONTAINER_ALPHA)
|
||||
.over(base);
|
||||
// 这里是**默认口径**(OnSurfaceVariant);按变体取值请走 [`disabled_content_over_role`]
|
||||
// ——FilledTonal 与三式 IconButton 的禁用内容是 `OnSurface`(A1)。
|
||||
let content = scheme
|
||||
.get(Role::OnSurfaceVariant)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(container);
|
||||
|
||||
(to_iced(container), to_iced(content))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
/// 契约 §3 的 α 全表:**钉住令牌值**(改令牌会让这条红,改组件抓不到)。
|
||||
#[test]
|
||||
fn state_layer_alphas_are_the_contract_values() {
|
||||
assert!((StateLayer::Hover.alpha() - 0.08).abs() < f32::EPSILON);
|
||||
assert!((StateLayer::Focus.alpha() - 0.10).abs() < f32::EPSILON);
|
||||
assert!((StateLayer::Pressed.alpha() - 0.10).abs() < f32::EPSILON);
|
||||
assert!((StateLayer::Dragged.alpha() - 0.16).abs() < f32::EPSILON);
|
||||
assert!((DISABLED_CONTAINER_ALPHA - 0.12).abs() < f32::EPSILON);
|
||||
assert!((DISABLED_CONTENT_ALPHA - 0.38).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_states_map_onto_four_layers() {
|
||||
assert_eq!(Interaction::ALL.len(), 5);
|
||||
assert_eq!(Interaction::Enabled.layer(), None);
|
||||
assert_eq!(Interaction::Disabled.layer(), None);
|
||||
assert_eq!(Interaction::Hovered.layer(), Some(StateLayer::Hover));
|
||||
assert_eq!(Interaction::Focused.layer(), Some(StateLayer::Focus));
|
||||
assert_eq!(Interaction::Pressed.layer(), Some(StateLayer::Pressed));
|
||||
// focus 与 pressed 同 α(M3 口径)——图上一样是**对的**
|
||||
assert_eq!(
|
||||
Interaction::Focused.layer().map(StateLayer::alpha),
|
||||
Interaction::Pressed.layer().map(StateLayer::alpha)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_color_is_the_token_blend_not_local_math() {
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let ui = ui(flavor);
|
||||
for (state, layer) in [
|
||||
(Interaction::Hovered, StateLayer::Hover),
|
||||
(Interaction::Focused, StateLayer::Focus),
|
||||
(Interaction::Pressed, StateLayer::Pressed),
|
||||
] {
|
||||
assert_eq!(
|
||||
overlay_color(&ui, state, Role::OnPrimary, Role::Primary),
|
||||
Some(ui.state_over(layer, Role::OnPrimary, Role::Primary)),
|
||||
"{flavor:?}/{state:?} 必须逐位等于令牌求值结果"
|
||||
);
|
||||
assert!(
|
||||
overlay_color(&ui, state, Role::OnSurface, Role::Surface).is_some(),
|
||||
"可交互态必须给颜色"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
overlay_color(&ui, Interaction::Enabled, Role::OnSurface, Role::Surface),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
overlay_color(&ui, Interaction::Disabled, Role::OnSurface, Role::Surface),
|
||||
None,
|
||||
"禁用态不叠状态层(它换的是容器/内容色)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_is_weaker_than_pressed_on_the_same_container() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let hover = overlay_color(&ui, Interaction::Hovered, Role::OnSurface, Role::Surface)
|
||||
.expect("hover 有颜色");
|
||||
let pressed = overlay_color(&ui, Interaction::Pressed, Role::OnSurface, Role::Surface)
|
||||
.expect("pressed 有颜色");
|
||||
assert_ne!(hover, pressed, "8% 与 10% 必须能区分");
|
||||
assert_eq!(hover.a, 1.0, "结果必须不透明(GPU/CPU 逐像素一致)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_colors_come_from_the_token_functions() {
|
||||
let ui = ui(SchemeFlavor::Light);
|
||||
let scheme = Scheme::baseline(SchemeFlavor::Light);
|
||||
|
||||
let (container, content) = disabled_colors(&ui, Role::Surface);
|
||||
let base = scheme.get(Role::Surface);
|
||||
let expected_container = scheme
|
||||
.get(Role::OnSurface)
|
||||
.with_alpha(DISABLED_CONTAINER_ALPHA)
|
||||
.over(base);
|
||||
assert_eq!(
|
||||
container,
|
||||
to_iced(expected_container),
|
||||
"容器 = on-surface@12% 叠表面"
|
||||
);
|
||||
assert_eq!(
|
||||
content,
|
||||
to_iced(
|
||||
scheme
|
||||
.get(Role::OnSurfaceVariant)
|
||||
.with_alpha(DISABLED_CONTENT_ALPHA)
|
||||
.over(expected_container)
|
||||
),
|
||||
);
|
||||
assert_ne!(container, ui.color(Role::Surface), "12% 叠上去必须能看出来");
|
||||
assert_eq!(container.a, 1.0, "禁用容器也是实色(CPU/GPU 一致)");
|
||||
assert_eq!(content.a, 1.0, "禁用内容也是实色");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 自定义 `Widget` 的状态机([`Tracker`]):把 iced 事件折成 [`Interaction`] 五态。
|
||||
//!
|
||||
//! 与 [`super`] 的分工:`super` 只有「态 → 颜色」的纯映射;**事件 → 态**在这里。
|
||||
//! 本 mission 的按钮家族全走 iced 的 `button`(自带四态),所以 `Tracker` 目前只有单测在用——
|
||||
//! 它给四个后续家族里**要自绘**的控件(Slider 轨道 / Switch 轨道 / 进度条)准备。
|
||||
//!
|
||||
//! 加东西前先看行数余额(单文件 ≤400,AGENTS.md §3-1)。
|
||||
|
||||
use iced::Event;
|
||||
|
||||
use super::Interaction;
|
||||
|
||||
/// 自定义 `Widget` 的状态机:把 iced 事件折成 [`Interaction`]。
|
||||
///
|
||||
/// 这是 D39 §3 说的「状态机的公共部分」。用法(自定义 Widget 的 `update` 里):
|
||||
///
|
||||
/// ```ignore
|
||||
/// if self.tracker.event(event, cursor.is_over(layout.bounds())) {
|
||||
/// shell.request_redraw(); // 只有真变了才重绘
|
||||
/// }
|
||||
/// let style = style_of(self.tracker.state());
|
||||
/// ```
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Tracker {
|
||||
enabled: bool,
|
||||
hovered: bool,
|
||||
focused: bool,
|
||||
pressed: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Tracker {
|
||||
/// 新建;`enabled` 由组件自己决定(例如 `on_press.is_some()`)。
|
||||
pub fn new(enabled: bool) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 启用/禁用热切换(禁用时清掉悬停与按下:禁用元素不该留着 hover 高亮)。
|
||||
pub fn set_enabled(&mut self, enabled: bool) -> bool {
|
||||
let changed = self.enabled != enabled;
|
||||
self.enabled = enabled;
|
||||
if !enabled {
|
||||
self.hovered = false;
|
||||
self.pressed = false;
|
||||
self.focused = false;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 键盘焦点(由 `operation`/`shell` 侧的聚焦消息驱动)。
|
||||
pub fn set_focused(&mut self, focused: bool) -> bool {
|
||||
let next = focused && self.enabled;
|
||||
let changed = self.focused != next;
|
||||
self.focused = next;
|
||||
changed
|
||||
}
|
||||
|
||||
/// 折进一个事件;返回「视觉态变了,要重绘吗」。
|
||||
///
|
||||
/// `over` = 指针是否在控件 bounds 内(`Cursor::is_over(bounds)` 的结果,由调用方算——
|
||||
/// 这样本函数不依赖 `Layout`,可以在单测里直接喂)。
|
||||
pub fn event(&mut self, event: &Event, over: bool) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
let before = self.state();
|
||||
match event {
|
||||
Event::Mouse(iced::mouse::Event::CursorMoved { .. }) => self.hovered = over,
|
||||
Event::Mouse(iced::mouse::Event::CursorLeft) => {
|
||||
self.hovered = false;
|
||||
self.pressed = false;
|
||||
}
|
||||
Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) => {
|
||||
self.pressed = over;
|
||||
}
|
||||
Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)) => {
|
||||
self.pressed = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.state() != before
|
||||
}
|
||||
|
||||
/// 当前态(优先级:按下 > 焦点 > 悬停 > 静息;禁用最高)。
|
||||
pub const fn state(&self) -> Interaction {
|
||||
if !self.enabled {
|
||||
Interaction::Disabled
|
||||
} else if self.pressed {
|
||||
Interaction::Pressed
|
||||
} else if self.focused {
|
||||
Interaction::Focused
|
||||
} else if self.hovered {
|
||||
Interaction::Hovered
|
||||
} else {
|
||||
Interaction::Enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Tracker` 的行为用例(自绘控件用,与颜色无关)。
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iced::Event;
|
||||
|
||||
fn moved() -> Event {
|
||||
Event::Mouse(iced::mouse::Event::CursorMoved {
|
||||
position: iced::Point::ORIGIN,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_folds_events_into_the_five_states() {
|
||||
let mut tracker = Tracker::new(true);
|
||||
assert_eq!(tracker.state(), Interaction::Enabled);
|
||||
|
||||
assert!(tracker.event(&moved(), true), "首次悬停要报变化");
|
||||
assert_eq!(tracker.state(), Interaction::Hovered);
|
||||
assert!(!tracker.event(&moved(), true), "重复悬停不该再报变化");
|
||||
|
||||
let press = Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left));
|
||||
assert!(tracker.event(&press, true));
|
||||
assert_eq!(tracker.state(), Interaction::Pressed);
|
||||
|
||||
let release = Event::Mouse(iced::mouse::Event::ButtonReleased(
|
||||
iced::mouse::Button::Left,
|
||||
));
|
||||
assert!(tracker.event(&release, true));
|
||||
assert_eq!(tracker.state(), Interaction::Hovered);
|
||||
|
||||
assert!(tracker.set_focused(true));
|
||||
assert_eq!(tracker.state(), Interaction::Focused, "焦点态优先于悬停");
|
||||
|
||||
let left = Event::Mouse(iced::mouse::Event::CursorLeft);
|
||||
tracker.event(&left, false);
|
||||
assert_eq!(tracker.state(), Interaction::Focused, "鼠标离开不影响焦点");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_tracker_swallows_everything() {
|
||||
let mut tracker = Tracker::new(true);
|
||||
assert!(tracker.set_enabled(false));
|
||||
assert_eq!(tracker.state(), Interaction::Disabled);
|
||||
|
||||
let press = Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left));
|
||||
assert!(!tracker.event(&press, true), "禁用态不响应事件");
|
||||
assert_eq!(tracker.state(), Interaction::Disabled);
|
||||
assert!(!tracker.set_focused(true), "禁用态拿不到焦点");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 布局陷阱的两个固定解法(D39 §4):**要精确尺寸就调这里,别手写**。
|
||||
//!
|
||||
//! ## 陷阱 Q8:`center_x(Length::Fill)` 会把已设的 `.width()` 覆盖成 Fill
|
||||
//!
|
||||
//! iced 的 `container::center_x/center_y/center` 不只是「对齐」——它们**同时**把容器该轴的
|
||||
//! `Length` 改成 `Fill`(源码里 `self.width = Length::Fill`)。于是
|
||||
//! `.width(56).center_x(Length::Fill)` 最终是 `Fill`,56 被父级宽度吃掉:
|
||||
//! M68 的导航药丸因此从 **56×32 变成 88×32**(父级是 88 宽的轨),图上看着「横贯全轨」,
|
||||
//! 而单元测试测的 `indicator_size()` 返回值一直是 56×32 —— **函数对、图错**。
|
||||
//!
|
||||
//! 正确写法是二选一:
|
||||
//!
|
||||
//! - 尺寸留在**自己**身上,居中交给**父级**(`row/column` 的 `align_x`)——`shell/nav.rs` 的做法;
|
||||
//! - 或者用 [`centered_fixed`]:容器写死宽高,**用 `align_x/align_y` 居中**(它们只改对齐,
|
||||
//! 不改 `Length`)。
|
||||
//!
|
||||
//! ## 陷阱 Q9:`Button` 给内容 Fill 约束,`svg().width(24)` 会被忽略
|
||||
//!
|
||||
//! `button` 在 layout 里把「内容可用区」作为约束交给子元素,`svg` 拿到的是 Fill,
|
||||
//! usvg 于是按 `Contain` 撑满内区:**字形渲染成 46px、线宽从 1.5 变 2.9**(M68 视觉复审 P2-2),
|
||||
//! 比导航图标重一倍。反过来「把 `.width(24)` 设在 button 上」也不行——那会缩掉点击区。
|
||||
//!
|
||||
//! 正确写法:**在中间插一层固定尺寸容器**——[`fixed`]:
|
||||
//!
|
||||
//! ```text
|
||||
//! let icon = layout::fixed(24.0, 24.0, icons::view(kind, color)); // 字形 24×24
|
||||
//! button(icon).width(46).height(48) // 命中区仍是 46×48
|
||||
//! ```
|
||||
//!
|
||||
//! ## 为什么还要 [`inset`]
|
||||
//!
|
||||
//! 「左右缩进 16 的一条线」「内容四周留 24」这类**只改内边距、不改尺寸**的语义,
|
||||
//! 写 `.padding(Padding::from([...]))` 时数组顺序(上右下左)极易写反;封装一次,
|
||||
//! 全组件线只写一次顺序。
|
||||
//!
|
||||
//! ## 自检
|
||||
//!
|
||||
//! `components::gate` 会扫 `src/components/**`:出现 `center_x(` / `center_y(` / `.center(`
|
||||
//! 即判红(这条门禁就是 Q8 的回归测试);图标一律经 [`fixed`] / [`centered_fixed`] 包一层。
|
||||
|
||||
use iced::widget::container;
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
/// 固定尺寸的盒子:宽高**只由参数决定**,子元素贴左上(对齐交给子元素自己或父级)。
|
||||
///
|
||||
/// Q9 的解法:`svg` 放进 24×24 的盒子,它拿到的约束就是 24,`Contain` 无从撑满。
|
||||
pub fn fixed<'a, M: 'a>(
|
||||
width: f32,
|
||||
height: f32,
|
||||
content: impl Into<Element<'a, M>>,
|
||||
) -> Element<'a, M> {
|
||||
container(content)
|
||||
.width(Length::Fixed(width))
|
||||
.height(Length::Fixed(height))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 固定尺寸 + 内容居中(Q8 的解法)。
|
||||
///
|
||||
/// 用 `align_x/align_y` 而不是 `center_x/center_y`:前者只改对齐,后者会把本容器的
|
||||
/// `Length` 改成 `Fill`,把参数里的宽高**静默丢掉**。
|
||||
///
|
||||
/// 典型用法:导航轨的选中指示器 [`centered_fixed(56.0, 32.0, icon)`](centered_fixed)。
|
||||
pub fn centered_fixed<'a, M: 'a>(
|
||||
width: f32,
|
||||
height: f32,
|
||||
content: impl Into<Element<'a, M>>,
|
||||
) -> Element<'a, M> {
|
||||
container(content)
|
||||
.width(Length::Fixed(width))
|
||||
.height(Length::Fixed(height))
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 只加内边距、不碰尺寸(`horizontal` = 左右,`vertical` = 上下)。
|
||||
///
|
||||
/// 语义等价于 `.padding(Padding { top: vertical, right: horizontal, bottom: vertical,
|
||||
/// left: horizontal })`,但只暴露两个参数——避免把「上右下左」写反。
|
||||
pub fn inset<'a, M: 'a>(
|
||||
horizontal: f32,
|
||||
vertical: f32,
|
||||
content: impl Into<Element<'a, M>>,
|
||||
) -> Element<'a, M> {
|
||||
container(content)
|
||||
.padding(Padding {
|
||||
top: vertical,
|
||||
right: horizontal,
|
||||
bottom: vertical,
|
||||
left: horizontal,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 单侧缩进(只缩左侧;分隔线的「列表内左缩进 16」用这个)。
|
||||
pub fn inset_start<'a, M: 'a>(start: f32, content: impl Into<Element<'a, M>>) -> Element<'a, M> {
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(Padding {
|
||||
left: start,
|
||||
..Padding::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Q8 的**形态级**钉法:这三个 API 一旦内部改用 `center_*`,这条测试之外的门禁
|
||||
/// (`components::gate`)也会红;这里再钉一次「参数就是参数」,防止签名被悄悄改成
|
||||
/// `impl Into<Length>` 之类。
|
||||
#[test]
|
||||
fn helpers_take_pixel_sizes_not_lengths() {
|
||||
let _: fn(f32, f32, Element<'static, ()>) -> Element<'static, ()> = fixed;
|
||||
let _: fn(f32, f32, Element<'static, ()>) -> Element<'static, ()> = centered_fixed;
|
||||
let _: fn(f32, f32, Element<'static, ()>) -> Element<'static, ()> = inset;
|
||||
let _: fn(f32, Element<'static, ()>) -> Element<'static, ()> = inset_start;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_build_a_tree_without_panicking() {
|
||||
let _: Element<'_, ()> = fixed(24.0, 24.0, iced::widget::text("x"));
|
||||
let _: Element<'_, ()> = centered_fixed(56.0, 32.0, iced::widget::text("x"));
|
||||
let _: Element<'_, ()> = inset(16.0, 8.0, iced::widget::text("x"));
|
||||
let _: Element<'_, ()> = inset_start(16.0, iced::widget::text("x"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! 组件库(D39 定案):五家族并行,**目录所有权切分**,接口见
|
||||
//! `DevDocs/SubAgentsTalk/app-iced-组件库契约.md`。
|
||||
//!
|
||||
//! ## 模块地图与归属(谁能写哪里)
|
||||
//!
|
||||
//! ```text
|
||||
//! components/
|
||||
//! mod.rs ← 本文件(COMPBASE 独占):只声明子模块,不含实现
|
||||
//! interaction.rs ← COMPBASE 独占:hover/focus/pressed/disabled 的唯一实现
|
||||
//! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法
|
||||
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
|
||||
//! divider.rs ← COMPBASE(15 §22)
|
||||
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
|
||||
//! inputs/ ← 同上(§9/§20/§11/§8)
|
||||
//! feedback/ ← 同上(§12/§14/§7/§15)
|
||||
//! navigation/ ← 同上(§4/§6/§21/§16)
|
||||
//! gallery/ ← 视觉验收 harness(`--screen=gallery`)
|
||||
//! ```
|
||||
//!
|
||||
//! **后续 mission 只写自己目录内的文件,不改本文件**:四个家族桩已经在这里声明好,
|
||||
//! 每个家族文件自带一个 `demo()`(占位 → 真组件),gallery 侧对应文件也在
|
||||
//! `gallery/` 里预置了同构的桩。加组件 = 只改自家目录里的文件。
|
||||
//!
|
||||
//! ## 组件签名纪律(契约 §2)
|
||||
//!
|
||||
//! 组件**对消息类型泛型**,不 import `crate::app::Message`:
|
||||
//!
|
||||
//! ```text
|
||||
//! pub fn filled<'a, M: Clone + 'a>(ui: &'a Ui, ...) -> Element<'a, M>
|
||||
//! ```
|
||||
//!
|
||||
//! `Ui` 一律 `&'a Ui` 传入,组件内部**不许** `Ui::new`;深浅热切靠外部重建整树。
|
||||
//!
|
||||
//! ## 令牌纪律
|
||||
//!
|
||||
//! 组件里**零**色值 / 字号 / 半径 / 间距 / α 字面量:颜色走 [`crate::theme::Ui`],
|
||||
//! 状态层走 [`interaction`],尺寸走 `unlucky_design` 的 `LayoutMetrics` / `Space` /
|
||||
//! `IconSize` / `ShapeScale`。缺令牌 → 登记 G 编号(续 15 §25 的 G13),**不发明**。
|
||||
//! [`gate`] 里的测试会扫本目录源码执行这条纪律。
|
||||
//!
|
||||
//! ## 为什么 `components` 由 `app.rs` 声明
|
||||
//!
|
||||
//! `src/lib.rs` 是五条组件线共用的文件(并行线改它必然冲突)。本模块挂在
|
||||
//! `app.rs` 的 `#[path]` 声明下,路径仍是 `src/components/`,这样 COMPBASE 的
|
||||
//! 全部改动都落在自己 scope 内。
|
||||
|
||||
pub mod buttons;
|
||||
pub mod divider;
|
||||
pub mod feedback;
|
||||
pub mod gallery;
|
||||
pub mod inputs;
|
||||
pub mod interaction;
|
||||
pub mod layout;
|
||||
pub mod navigation;
|
||||
pub mod selection;
|
||||
|
||||
#[cfg(test)]
|
||||
mod gate;
|
||||
@@ -0,0 +1,35 @@
|
||||
//! **导航类家族(桩)**:由后续 mission 填充(§4 ListItem / §6 Tabs / §21 NavigationDrawer / §16 AppBar)。
|
||||
//!
|
||||
//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**:
|
||||
//! 家族模块已经在 `mod.rs` 里声明好,`gallery/navigation.rs` 也已经在 `gallery/mod.rs` 里声明好,
|
||||
//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。
|
||||
//!
|
||||
//! 填充时请照抄按钮家族的分层:
|
||||
//!
|
||||
//! - 几何进 `navigation/geometry.rs`(数值从 `ComponentMetrics::NAVIGATION_DRAWER` /
|
||||
//! `LayoutMetrics` / `Space` / `ShapeScale` 取,缺令牌登记 G 编号);
|
||||
//! - 状态层只能走 [`crate::app::components::interaction`];
|
||||
//! - 精确尺寸用 [`crate::app::components::layout`]——导航选中指示器 **56×32** 就是 Q8 的历史事故现场,
|
||||
//! 必须用 `layout::centered_fixed`(`center_x(Length::Fill)` 会把它撑成 88 宽)。
|
||||
|
||||
use iced::widget::{container, text};
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 占位 demo(ASCII 文案,理由同 `selection::demo`)。
|
||||
pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text("navigation · list item / tabs / drawer / app bar — pending")
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.padding(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! **选择类家族(桩)**:由后续 mission 填充(§18 Checkbox / §19 Radio / §10 Switch / §13 Chip)。
|
||||
//!
|
||||
//! 本文件目前只有占位 `demo()`。**后续 mission 只写本目录内的文件,不改 `components/mod.rs`**:
|
||||
//! 家族模块已经在 `mod.rs` 里声明好,`gallery/selection.rs` 也已经在 `gallery/mod.rs` 里声明好,
|
||||
//! 所以「加组件」不需要碰任何共享文件(D39 §1 的并行地基)。
|
||||
//!
|
||||
//! 填充时请照抄按钮家族的分层:
|
||||
//!
|
||||
//! - 几何进 `selection/geometry.rs`(数值一律从 `ComponentMetrics` / `Space` / `ShapeScale` 取,
|
||||
//! 缺令牌登记 G 编号);
|
||||
//! - 状态层只能走 [`crate::app::components::interaction`](α 一处定义);
|
||||
//! - 精确尺寸用 [`crate::app::components::layout`](Q8/Q9 的解法),别手写 `.width(x)`。
|
||||
|
||||
use iced::widget::{container, text};
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 占位 demo:一句 "pending",供 `--screen=gallery` 保持五段结构完整。
|
||||
///
|
||||
/// 后续 mission 把本函数换成真组件矩阵即可(`gallery/selection.rs` 调的就是它)。
|
||||
/// 文案用 ASCII:干净 CI 上可能没有中文字体(`fonts` 只保证拉丁族名),豆腐块会污染验收图。
|
||||
pub fn demo<'a, M: 'a>(ui: &'a Ui) -> Element<'a, M> {
|
||||
let style = ui.style(TypeRole::BodySmall);
|
||||
container(
|
||||
text("selection · checkbox / radio / switch / chip — pending")
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
)
|
||||
.padding(ui.space(Space::X2))
|
||||
.into()
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
//! 等价验收后翻牌(bin 改名 + 旧 crate 降级),步骤写在
|
||||
//! `DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md`。
|
||||
|
||||
pub mod pages;
|
||||
pub mod shot;
|
||||
pub mod theme;
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! 下载页:**单列**版面的用例(列宽 ≤424,水平居中)。
|
||||
//!
|
||||
//! 这一页永远单列,理由不是「简单」,而是官方对「一次只做一件事」的入口(下载队列)的口径:
|
||||
//! 内容是一串线性进度项,分栏没有第二个阅读对象。窗口变宽时**正文列不变宽**(撞行宽上限),
|
||||
//! 多出来的宽度留白——这就是 M68「全宽单卡」的反面。
|
||||
//!
|
||||
//! 页头 1 个操作 + 空态 3 个快捷入口(12b §15.5:≤3)。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::frame::{self, Header};
|
||||
use super::layout::FrameMetrics;
|
||||
use super::{Action, Message};
|
||||
|
||||
/// 整页。
|
||||
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
|
||||
let header = Header::new("Download")
|
||||
.subtitle("Versions, mod loaders and modpacks")
|
||||
.action(frame::text_action(
|
||||
ui,
|
||||
"Refresh sources",
|
||||
Action::RefreshSources,
|
||||
));
|
||||
|
||||
let empty = frame::empty_state(
|
||||
ui,
|
||||
IconKind::Download,
|
||||
"No downloads in progress",
|
||||
"Pick a version or drop a modpack file here. Sources are probed per region and can be pinned \
|
||||
in Settings → Downloads.",
|
||||
vec![
|
||||
frame::text_action(ui, "Browse versions", Action::BrowseVersions),
|
||||
frame::text_action(ui, "Import modpack", Action::ImportModpack),
|
||||
frame::text_action(ui, "Check sources", Action::CheckSources),
|
||||
],
|
||||
);
|
||||
|
||||
let body = frame::single_column(
|
||||
metrics,
|
||||
column![source_status(ui), empty]
|
||||
.spacing(ui.space(Space::X4))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
);
|
||||
|
||||
frame::page(ui, metrics, header, body)
|
||||
}
|
||||
|
||||
/// 来源状态行(12pt,`OnSurfaceVariant`):说明这一页的数据从哪来,不是业务数据。
|
||||
fn source_status<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
container(
|
||||
row![
|
||||
frame::glyph(
|
||||
IconKind::Check,
|
||||
ui.color(Role::Secondary),
|
||||
frame::ICON_SMALL_PX
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
"3 sources healthy · BMCLAPI pinned by region · 2 min ago",
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit};
|
||||
|
||||
/// 单列页:任何窗口宽度都不许出现第二列,正文列不许超过行宽上限。
|
||||
#[test]
|
||||
fn download_is_single_column_at_every_width() {
|
||||
for width in [600.0, 840.0, 900.0, 1180.0, 2400.0] {
|
||||
let metrics = frame_metrics(PaneKit::Single, width);
|
||||
assert!(metrics.secondary.is_none(), "{width} 宽下载页出现了第二列");
|
||||
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
|
||||
assert_eq!(metrics.column, content_column_width(width));
|
||||
}
|
||||
}
|
||||
|
||||
/// 宽窗下多出来的宽度必须留白(不是把卡片拉宽)——1180 的正文列 < 可用宽度。
|
||||
#[test]
|
||||
fn extra_width_goes_to_margin_not_to_the_column() {
|
||||
let metrics = frame_metrics(PaneKit::Single, 1180.0);
|
||||
assert_eq!(metrics.column, measure_cap_px());
|
||||
assert!(
|
||||
metrics.available - metrics.column >= 400.0,
|
||||
"1180 宽下应留出大片留白(M68 的全宽卡就是没有这段留白)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! 页面骨架:**页头**(标题 + 副标题 + 可选操作区)+ **内容区**(列宽由 [`super::layout`] 决定)
|
||||
//! + **空态位**。
|
||||
//!
|
||||
//! ## 边界
|
||||
//!
|
||||
//! 这里只放「页面级」的东西:页头、页边距、内容列、两列骨架、空态位的摆放。
|
||||
//! 具体控件(卡片 / 分隔线 / 文本按钮 / 图标)在 [`pieces`],它们是**占位实现**
|
||||
//! (同一份令牌,没有完整状态机),等 M75 的 `components/` 家族合入后由另一条 mission 换掉。
|
||||
//!
|
||||
//! ## 判据落点(`research/12b`)
|
||||
//!
|
||||
//! | 部件 | 参考 | 落点 |
|
||||
//! |---|---|---|
|
||||
//! | 页头 | 16.1 AppBar 高 56(登记偏离官方 64)/ 两行 84 | `LayoutMetrics::header_height` |
|
||||
//! | 页头标题 | 16.4 `TitleLarge 22/28` | `Component::TopAppBar.spec().text` |
|
||||
//! | 页头副标题 | 16.4 `LabelMedium 12/16` `OnSurfaceVariant` | 同上 `.support` |
|
||||
//! | 空态 | 15.1 图标块 96×96 / `Large(16)`;15.2 标题 `HeadlineSmall 24`;15.3 副文案 `BodySmall 12` | `Component::EmptyState` |
|
||||
|
||||
mod pieces;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use pieces::{
|
||||
card, divider, empty_state, filled, gap, glyph, glyph_box, scroll_style, text_action,
|
||||
text_role, text_role_centered, ICON_PX, ICON_SMALL_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH,
|
||||
};
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::layout::{self, FrameMetrics};
|
||||
use super::Message;
|
||||
|
||||
/// 页头操作区之间的间隔(`Space::X2`)。
|
||||
const HEADER_ACTION_GAP: f32 = Space::X2.dp();
|
||||
|
||||
/// 页头内容。
|
||||
pub struct Header<'a> {
|
||||
/// 标题(`TitleLarge`)。
|
||||
pub title: &'a str,
|
||||
/// 副标题(`LabelMedium`,给了页头就是两行高)。
|
||||
pub subtitle: Option<&'a str>,
|
||||
/// 右侧操作区(text button 占位,数量 ≤3)。
|
||||
pub actions: Vec<Element<'a, Message>>,
|
||||
}
|
||||
|
||||
impl<'a> Header<'a> {
|
||||
/// 单行页头。
|
||||
pub fn new(title: &'a str) -> Self {
|
||||
Self {
|
||||
title,
|
||||
subtitle: None,
|
||||
actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 加副标题(页头自动变两行高)。
|
||||
#[must_use]
|
||||
pub fn subtitle(mut self, subtitle: &'a str) -> Self {
|
||||
self.subtitle = Some(subtitle);
|
||||
self
|
||||
}
|
||||
|
||||
/// 追加一个右侧操作。
|
||||
#[must_use]
|
||||
pub fn action(mut self, action: Element<'a, Message>) -> Self {
|
||||
self.actions.push(action);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 一整页:页头 + 内容区(页边距已由布局系统决定)。
|
||||
pub fn page<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
header: Header<'a>,
|
||||
body: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let bar = header_bar(ui, metrics, header);
|
||||
let content = container(body)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding(metrics.gutter);
|
||||
|
||||
column![bar, content]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn header_bar<'a>(ui: &'a Ui, metrics: FrameMetrics, header: Header<'a>) -> Element<'a, Message> {
|
||||
let spec = Component::TopAppBar.spec();
|
||||
// 16.4:标题 TitleLarge(emphasized w500),副标题 LabelMedium + OnSurfaceVariant
|
||||
let mut titles = column![text_role(
|
||||
ui,
|
||||
spec.text,
|
||||
true,
|
||||
header.title,
|
||||
ui.color(Role::OnSurface)
|
||||
)]
|
||||
.spacing(ui.space(Space::X1));
|
||||
if let Some(subtitle) = header.subtitle {
|
||||
titles = titles.push(text_role(
|
||||
ui,
|
||||
spec.support.unwrap_or(TypeRole::LabelMedium),
|
||||
false,
|
||||
subtitle,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
|
||||
let actions = row(header.actions)
|
||||
.spacing(HEADER_ACTION_GAP)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
container(
|
||||
row![titles.width(Length::Fill), actions]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(ui.metrics().header_height(header.subtitle.is_some()))
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding::from([0.0, metrics.gutter]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 单列版面的正文列:宽 = 行宽上限内的实际列宽,**水平居中**。
|
||||
pub fn single_column<'a>(
|
||||
metrics: FrameMetrics,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
centered(metrics.column, content)
|
||||
}
|
||||
|
||||
/// 两列版面:左列 `secondary`(独立滚动区由调用方自己加 `scrollable`)+ 右列 `main`。
|
||||
///
|
||||
/// 两列都**显式**取布局系统给的宽度,不吃 Fill:列表列必须固定 336,
|
||||
/// 详情列必须窄于可用宽度,否则又回到「全宽单卡」。
|
||||
pub fn panes<'a>(
|
||||
metrics: FrameMetrics,
|
||||
secondary: impl Into<Element<'a, Message>>,
|
||||
main: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let width = metrics.secondary.unwrap_or(0.0);
|
||||
row![
|
||||
container(secondary)
|
||||
.width(Length::Fixed(width))
|
||||
.height(Length::Fill),
|
||||
container(main)
|
||||
.width(Length::Fixed(metrics.main_pane))
|
||||
.height(Length::Fill),
|
||||
]
|
||||
.spacing(layout::PANES_GAP)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 把内容塞进固定宽度并水平居中(`align_x` 不会覆盖子元素的宽度,见 Q8)。
|
||||
pub fn centered<'a>(width: f32, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
container(container(content).width(Length::Fixed(width)))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Start)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 与 [`centered`] 同宽,但**贴左**:列表右侧的详情列要贴着自己的 pane 左边,不再二次居中。
|
||||
pub fn left_aligned<'a>(
|
||||
width: f32,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
container(container(content).width(Length::Fixed(width)))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Start)
|
||||
.align_y(Alignment::Start)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! 页面用的**占位控件**:卡片 / 分隔线 / 空态 / 文本按钮 / 图标 / 容器样式。
|
||||
//!
|
||||
//! 全部走令牌(颜色、圆角、排版、间距、一像素线),但**没有完整状态机**——
|
||||
//! 这些是给页面骨架站稳版式用的临时件,M74/M75 的 `components/` 家族合入后由另一条 mission 替换。
|
||||
//! 每个函数都在文档里点明它对应哪个组件的档位(12b 的参考值)。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{button, column, container, row, rule, scrollable, space, svg};
|
||||
use iced::Theme as IcedTheme;
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
|
||||
|
||||
use unlucky_design::color::{Role, StateLayer};
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::icons::{IconKind, IconPaint};
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::{TypeRole, TypeStyle};
|
||||
|
||||
use crate::icons;
|
||||
use crate::pages::{Action, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 空态图标块边长(12b 15.1:96×96,圆角 `Large`)。
|
||||
pub(super) const EMPTY_ICON_BLOCK: f32 = 96.0;
|
||||
/// 空态字形渲染尺寸(12b 15.1:图标 32~48 `[估]`,取 40)。
|
||||
pub(super) const EMPTY_ICON_GLYPH: f32 = 40.0;
|
||||
/// 滚动条轨宽(视觉复审 P2-2:原来用 iced 默认的 10px,压过了列表内容)。
|
||||
///
|
||||
/// 取 6:细到不抢视线,又还在命中/可见的下限之上;滑块做成 full 圆角药丸(半径 3)。
|
||||
pub const SCROLLBAR_WIDTH: f32 = 6.0;
|
||||
/// 滑块离卡片内边的留白(2px,避免贴边)。
|
||||
pub const SCROLLBAR_MARGIN: f32 = 2.0;
|
||||
|
||||
/// 描边卡片(占位:M75 的 `components` 会提供真正的 `OutlinedCard`)。
|
||||
pub fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
let spec = Component::OutlinedCard.spec();
|
||||
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),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一像素分隔线(`OutlineVariant`,厚度取令牌)。
|
||||
pub fn divider<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
rule::horizontal::<IcedTheme>(ui.metrics().divider_thickness)
|
||||
.style(move |_theme| rule::Style {
|
||||
color: ui.color(Role::OutlineVariant),
|
||||
radius: ui.radius_dp(ShapeScale::None),
|
||||
fill_mode: rule::FillMode::Full,
|
||||
snap: true,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 空态(12b §15):图标块 96 + 24 标题 + 12 副文案 + ≤3 个快捷入口,**整块垂直居中**。
|
||||
pub fn empty_state<'a>(
|
||||
ui: &'a Ui,
|
||||
icon: IconKind,
|
||||
title: &'a str,
|
||||
body: &'a str,
|
||||
actions: Vec<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let spec = Component::EmptyState.spec();
|
||||
|
||||
let block = container(glyph(
|
||||
icon,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
EMPTY_ICON_GLYPH,
|
||||
))
|
||||
.width(EMPTY_ICON_BLOCK)
|
||||
.height(EMPTY_ICON_BLOCK)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.color(Role::SurfaceContainerHigh))),
|
||||
border: Border {
|
||||
radius: ui.radius_dp(spec.shape),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
});
|
||||
|
||||
let mut stack = column![
|
||||
block,
|
||||
text_role_centered(ui, spec.text, false, title, ui.color(Role::OnSurface)),
|
||||
text_role_centered(
|
||||
ui,
|
||||
spec.support.unwrap_or(TypeRole::BodySmall),
|
||||
false,
|
||||
body,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.align_x(Alignment::Center);
|
||||
|
||||
if !actions.is_empty() {
|
||||
stack = stack.push(
|
||||
row(actions)
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
}
|
||||
|
||||
container(stack)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 文本按钮占位(TextButton 档:透明底、`OnSurfaceVariant`、40 高、full 圆角)。
|
||||
///
|
||||
/// 只画静息、hover、pressed 三态(focus/disabled 归 M75 的 `interaction.rs`);
|
||||
/// 消息是页面自己的 [`Message::Action`],与 app 的 `Message` 完全解耦。
|
||||
pub fn text_action<'a>(ui: &'a Ui, label: &'a str, action: Action) -> Element<'a, Message> {
|
||||
let spec = Component::TextButton.spec();
|
||||
let height = ui.metrics().control_height;
|
||||
|
||||
let content = text_role(
|
||||
ui,
|
||||
spec.text,
|
||||
false,
|
||||
label,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
);
|
||||
|
||||
button(container(content).align_y(Alignment::Center))
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X4)]))
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = match status {
|
||||
button::Status::Hovered => Some(Background::Color(ui.state_over(
|
||||
StateLayer::Hover,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
))),
|
||||
button::Status::Pressed => Some(Background::Color(ui.state_over(
|
||||
StateLayer::Pressed,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
))),
|
||||
button::Status::Active | button::Status::Disabled => None,
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background,
|
||||
text_color: ui.color(Role::OnSurfaceVariant),
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, height),
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
})
|
||||
.on_press(Message::Action(action))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 令牌排版档 → 文字元素(字号 / 行高 / 字重全来自令牌,不写字面量)。
|
||||
pub fn text_role<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
emphasized: bool,
|
||||
content: &'a str,
|
||||
color: Color,
|
||||
) -> Element<'a, Message> {
|
||||
text_widget(ui, role, emphasized, content, color).into()
|
||||
}
|
||||
|
||||
/// 同 [`text_role`],但**文字自身居中**(空态的标题与副文案:图标块是居中锚点)。
|
||||
pub fn text_role_centered<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
emphasized: bool,
|
||||
content: &'a str,
|
||||
color: Color,
|
||||
) -> Element<'a, Message> {
|
||||
text_widget(ui, role, emphasized, content, color)
|
||||
.align_x(iced::widget::text::Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn text_widget<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
emphasized: bool,
|
||||
content: &'a str,
|
||||
color: Color,
|
||||
) -> iced::widget::Text<'a> {
|
||||
let style: TypeStyle = if emphasized {
|
||||
ui.emphasized(role)
|
||||
} else {
|
||||
ui.style(role)
|
||||
};
|
||||
|
||||
iced::widget::text(content)
|
||||
.size(style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||
.font(ui.font_of(style))
|
||||
.color(color)
|
||||
}
|
||||
|
||||
/// 任意尺寸的令牌图标(`icons::RENDER_PX` 只有 24 一档;空态图标要 40)。
|
||||
///
|
||||
/// ## 图标语汇(视觉复审 P2-3)
|
||||
///
|
||||
/// 令牌里 **`Play` / `Stop` / `More` 是填充字形**,其余全是描边。页面骨架里**只用描边图标**:
|
||||
/// 同屏把填充字形和描边字形混在一起,线宽语汇会打架(复审的原话是「Play 是填充字形,
|
||||
/// 同屏其它图标是描边」)。真要做「主操作允许实心图标」这条例外,应当由组件线在
|
||||
/// `components/` 里统一定义并只保留一处,而不是散落在页面里——所以这里干脆一个都不放。
|
||||
/// 守门:`tests/no_hardcoded_tokens.rs` 的 `pages_use_only_stroked_icons`。
|
||||
///
|
||||
/// 描边宽度仍按令牌的 `Icon::stroke_width(render_px)` 折算,所以视觉线宽在任何尺寸下都是 1.5。
|
||||
pub fn glyph(kind: IconKind, color: Color, render_px: f32) -> Element<'static, Message> {
|
||||
let icon = kind.icon();
|
||||
let paint = match icon.paint {
|
||||
IconPaint::Stroke => format!(
|
||||
"fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \
|
||||
stroke-linecap=\"round\" stroke-linejoin=\"round\"",
|
||||
icon.stroke_width(render_px)
|
||||
),
|
||||
IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(),
|
||||
};
|
||||
|
||||
let source = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" \
|
||||
viewBox=\"0 0 24 24\"><path d=\"{1}\" {paint}/></svg>",
|
||||
render_px, icon.path
|
||||
);
|
||||
|
||||
svg(svg::Handle::from_memory(source.into_bytes()))
|
||||
.width(render_px)
|
||||
.height(render_px)
|
||||
.style(move |_theme, _status| svg::Style { color: Some(color) })
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 固定宽 × 固定高的图标容器(Q9:按钮给的 Fill 约束会撑大字形,必须中间夹一层)。
|
||||
pub fn glyph_box(kind: IconKind, color: Color, px: f32) -> Element<'static, Message> {
|
||||
container(glyph(kind, color, px))
|
||||
.width(px)
|
||||
.height(px)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 竖直留白(布局拼装用;值来自令牌栅格)。
|
||||
pub fn gap<'a>(px: f32) -> Element<'a, Message> {
|
||||
space::vertical().height(px).into()
|
||||
}
|
||||
|
||||
/// 纯色容器样式(页面底色 / 留白带)。
|
||||
pub fn filled(color: Color) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(color)),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 滚动条配色(令牌角色):轨道透明、滑块 `OutlineVariant`(低对比,弱于正文),
|
||||
/// 轨宽 `SCROLLBAR_WIDTH`(6px)+ `SCROLLBAR_MARGIN`(2px 内缩)——视觉 r1 P2-2 的修法。
|
||||
///
|
||||
/// 只在**独立滚动区**上挂(实例页列表列),不许挂到整页上——整页滚动会把页头一起滚走。
|
||||
pub fn scroll_style(ui: &Ui) -> impl Fn(&IcedTheme, scrollable::Status) -> scrollable::Style + '_ {
|
||||
let rail = scrollable::Rail {
|
||||
background: None,
|
||||
border: Border::default(),
|
||||
scroller: scrollable::Scroller {
|
||||
// 低对比:用 `OutlineVariant`(比 `Outline` 弱一档的容器描边角色)而不是 `Outline`,
|
||||
// 让滑块明显弱于正文;纯色不透明,CPU/GPU 两档一致。
|
||||
background: Background::Color(ui.color(Role::OutlineVariant)),
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, SCROLLBAR_WIDTH),
|
||||
..Border::default()
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
move |_theme, _status| scrollable::Style {
|
||||
container: container::Style::default(),
|
||||
vertical_rail: rail,
|
||||
horizontal_rail: rail,
|
||||
gap: None,
|
||||
auto_scroll: scrollable::AutoScroll {
|
||||
background: Background::Color(ui.color(Role::SurfaceContainerHigh)),
|
||||
border: Border::default(),
|
||||
shadow: Shadow::default(),
|
||||
icon: ui.color(Role::OnSurfaceVariant),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `crate::icons::RENDER_PX` 的再导出(页面里 24px 图标走这个常量,不写字面量)。
|
||||
pub const ICON_PX: f32 = icons::RENDER_PX;
|
||||
|
||||
/// 小号图标(行尾 chevron 之类的辅助指示,16px)。
|
||||
pub const ICON_SMALL_PX: f32 = 16.0;
|
||||
@@ -0,0 +1,101 @@
|
||||
//! `frame` 的断言:空态 / 卡片 / 页头规格与一屏字号档。
|
||||
use super::pieces::{EMPTY_ICON_BLOCK, EMPTY_ICON_GLYPH};
|
||||
use super::*;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::pages::layout::{frame_metrics, PaneKit};
|
||||
use crate::theme::Families;
|
||||
|
||||
fn ui() -> Ui {
|
||||
Ui::with_platform(
|
||||
SchemeFlavor::Light,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_state_follows_the_12b_reference() {
|
||||
// 15.1 图标块 96×96、圆角 Large(16);15.2 标题 HeadlineSmall;15.3 副文案 BodySmall
|
||||
let spec = Component::EmptyState.spec();
|
||||
assert_eq!(EMPTY_ICON_BLOCK, 96.0);
|
||||
assert_eq!(spec.shape, ShapeScale::Large);
|
||||
assert_eq!(spec.text, TypeRole::HeadlineSmall);
|
||||
assert_eq!(ui().style(spec.text).size_px, 24.0);
|
||||
assert_eq!(spec.support, Some(TypeRole::BodySmall));
|
||||
assert_eq!(ui().style(TypeRole::BodySmall).size_px, 12.0);
|
||||
// 字形 32~48([估])
|
||||
assert!((32.0..=48.0).contains(&EMPTY_ICON_GLYPH));
|
||||
const { assert!(EMPTY_ICON_GLYPH < EMPTY_ICON_BLOCK) };
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_header_uses_the_two_documented_heights() {
|
||||
let ui = ui();
|
||||
assert_eq!(ui.metrics().header_height(false), 56.0);
|
||||
assert_eq!(ui.metrics().header_height(true), 84.0);
|
||||
}
|
||||
|
||||
/// 一屏字号 ≤4 档(rubric D4)。规格表来源:12b §16.4 / §15 / 3.x OutlinedCard。
|
||||
#[test]
|
||||
fn every_screen_keeps_at_most_four_text_sizes() {
|
||||
let ui = ui();
|
||||
|
||||
let header = ui.emphasized(Component::TopAppBar.spec().text).size_px;
|
||||
let row_title = ui.style(Component::ListItem.spec().text).size_px;
|
||||
let row_support = ui
|
||||
.style(Component::ListItem.spec().support.unwrap())
|
||||
.size_px;
|
||||
let action = ui.style(Component::TextButton.spec().text).size_px;
|
||||
let body = ui.style(TypeRole::BodyMedium).size_px;
|
||||
let empty_title = ui.style(Component::EmptyState.spec().text).size_px;
|
||||
|
||||
let screens: [(&str, &[f32]); 3] = [
|
||||
// 实例页(list-detail 与窄窗单列):页头 + 行标题 + 行副文案 + 操作/正文
|
||||
("instances", &[header, row_title, row_support, action, body]),
|
||||
// 设置页:页头 + 分组项/操作 + 行标签 + 行值 + 空态标题
|
||||
(
|
||||
"settings",
|
||||
&[header, action, row_support, body, empty_title],
|
||||
),
|
||||
// 下载 / 日志:页头 + 空态标题 + 空态副文案 + 操作
|
||||
("empty-pages", &[header, empty_title, row_support, action]),
|
||||
];
|
||||
|
||||
for (name, sizes) in screens {
|
||||
let mut unique = sizes.to_vec();
|
||||
unique.sort_by(f32::total_cmp);
|
||||
unique.dedup();
|
||||
assert!(
|
||||
unique.len() <= 4,
|
||||
"{name} 一屏用了 {} 档字号 {unique:?},超过 rubric D4 的 4 档",
|
||||
unique.len()
|
||||
);
|
||||
assert!(unique[0] >= 12.0, "{name} 出现小于 12px 的字号");
|
||||
assert!(unique[unique.len() - 1] <= 24.0, "{name} 字号过大");
|
||||
}
|
||||
}
|
||||
|
||||
/// 版面几何是页面唯一宽度来源:两列版面的正文列必须窄于可用宽度。
|
||||
#[test]
|
||||
fn two_pane_body_is_narrower_than_the_available_width() {
|
||||
let wide = frame_metrics(PaneKit::ListDetail, 1180.0);
|
||||
assert!(wide.column < wide.available);
|
||||
assert!(wide.secondary.is_some());
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//! 截图 harness:把**一页**按给定窗口宽度 / 主题单独渲染并出图。
|
||||
//!
|
||||
//! 它是 `examples/pages_demo.rs` 的入口(该 example 只有三行)。**刻意不接 `cli.rs`**:
|
||||
//! `cli.rs` 归 M75 改(它正在加 `--screen`),本 mission 不碰;等 `cli.rs` 具备 `--screen` /
|
||||
//! `--window-width` 之后,这个 harness 应当删掉,改由正式启动参数出图。
|
||||
//!
|
||||
//! ## 用法
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p unlucky-app-iced --example pages_demo -- \
|
||||
//! --screen=instances --width=1180 --height=760 \
|
||||
//! --screenshot=out.png --screenshot-theme=light --software-renderer
|
||||
//! ```
|
||||
//!
|
||||
//! 参数:`--screen=instances|download|settings|logs`、`--width`、`--height`、
|
||||
//! `--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
|
||||
//! `--software-renderer`、`--scale-factor`。没给 `--screenshot` 就只是开窗看。
|
||||
//!
|
||||
//! ## 为什么还要等 3 帧
|
||||
//!
|
||||
//! 与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,且字体首次上屏会抖动。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use iced::{window, Element, Subscription, Task};
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
|
||||
use crate::cli::Backend;
|
||||
use crate::fonts;
|
||||
use crate::probe::{self, Probe};
|
||||
use crate::shot;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{demo, Screen};
|
||||
|
||||
/// 截图落盘的帧号(见模块文档)。
|
||||
const CAPTURE_AT_FRAME: u32 = 3;
|
||||
/// 验收窗口尺寸(D30:1180×760、100% DPI)。
|
||||
const DEFAULT_SIZE: (f32, f32) = (1180.0, 760.0);
|
||||
|
||||
/// 一次渲染请求。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Request {
|
||||
/// 渲染哪一页。
|
||||
pub screen: Screen,
|
||||
/// 窗口宽度(**含外壳**:布局档位就是按它判的)。
|
||||
pub width: f32,
|
||||
/// 窗口高度。
|
||||
pub height: f32,
|
||||
/// 主题(`--screenshot-theme` > `--theme` > 浅色)。
|
||||
pub flavor: SchemeFlavor,
|
||||
/// 出图路径(`None` = 只开窗)。
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// 渲染后端请求。
|
||||
pub backend: Backend,
|
||||
/// 强制缩放因子(截图要 100% DPI)。
|
||||
pub scale_factor: Option<f32>,
|
||||
}
|
||||
|
||||
impl Default for Request {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
screen: Screen::Instances,
|
||||
width: DEFAULT_SIZE.0,
|
||||
height: DEFAULT_SIZE.1,
|
||||
flavor: SchemeFlavor::Light,
|
||||
screenshot: None,
|
||||
backend: Backend::Auto,
|
||||
scale_factor: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// 从进程参数解析(`argv[1..]`)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 未知参数、缺值、数字非法时返回人话。
|
||||
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
|
||||
let mut request = Self::default();
|
||||
let mut theme: Option<SchemeFlavor> = None;
|
||||
let mut shot_theme: Option<SchemeFlavor> = None;
|
||||
let mut args = args.into_iter();
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
let (key, inline) = match arg.split_once('=') {
|
||||
Some((key, value)) => (key.to_owned(), Some(value.to_owned())),
|
||||
None => (arg.clone(), None),
|
||||
};
|
||||
|
||||
match key.as_str() {
|
||||
"--screen" => {
|
||||
let raw = take(&mut args, inline, "--screen")?;
|
||||
request.screen = Screen::from_slug(&raw).ok_or_else(|| {
|
||||
format!("--screen 只认 instances|download|settings|logs,收到 {raw:?}")
|
||||
})?;
|
||||
}
|
||||
"--width" => {
|
||||
request.width = number(&take(&mut args, inline, "--width")?, "--width")?
|
||||
}
|
||||
"--height" => {
|
||||
request.height = number(&take(&mut args, inline, "--height")?, "--height")?;
|
||||
}
|
||||
"--theme" => theme = Some(flavor(&take(&mut args, inline, "--theme")?)?),
|
||||
"--screenshot-theme" => {
|
||||
shot_theme = Some(flavor(&take(&mut args, inline, "--screenshot-theme")?)?);
|
||||
}
|
||||
"--screenshot" => {
|
||||
request.screenshot =
|
||||
Some(PathBuf::from(take(&mut args, inline, "--screenshot")?));
|
||||
}
|
||||
"--software-renderer" => request.backend = Backend::Cpu,
|
||||
"--renderer" => {
|
||||
request.backend = match take(&mut args, inline, "--renderer")?.as_str() {
|
||||
"auto" => Backend::Auto,
|
||||
"gpu" => Backend::Gpu,
|
||||
"cpu" => Backend::Cpu,
|
||||
other => {
|
||||
return Err(format!("--renderer 只认 auto|gpu|cpu,收到 {other:?}"))
|
||||
}
|
||||
};
|
||||
}
|
||||
"--scale-factor" => {
|
||||
let raw = take(&mut args, inline, "--scale-factor")?;
|
||||
let value = number(&raw, "--scale-factor")?;
|
||||
if value <= 0.0 {
|
||||
return Err(format!("--scale-factor 必须 > 0,收到 {raw:?}"));
|
||||
}
|
||||
request.scale_factor = Some(value);
|
||||
}
|
||||
other => return Err(format!("未知参数 {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
request.flavor = shot_theme.or(theme).unwrap_or(SchemeFlavor::Light);
|
||||
|
||||
if request.width < 320.0 || request.height < 320.0 {
|
||||
return Err(format!(
|
||||
"窗口太小:{}×{}(至少 320×320)",
|
||||
request.width, request.height
|
||||
));
|
||||
}
|
||||
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// 出图文件名片段(`<screen>-<class>-<flavor>`,归档用)。
|
||||
pub fn slug(&self) -> String {
|
||||
let class = super::layout::WidthClass::of(self.width);
|
||||
let flavor = if self.flavor.is_dark() {
|
||||
"dark"
|
||||
} else {
|
||||
"light"
|
||||
};
|
||||
format!(
|
||||
"{}-{}-{flavor}-w{}",
|
||||
self.screen.slug(),
|
||||
class.name(),
|
||||
self.width as u32
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn take(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
inline: Option<String>,
|
||||
flag: &str,
|
||||
) -> Result<String, String> {
|
||||
inline
|
||||
.or_else(|| args.next())
|
||||
.ok_or_else(|| format!("{flag} 需要一个值"))
|
||||
}
|
||||
|
||||
fn number(raw: &str, flag: &str) -> Result<f32, String> {
|
||||
raw.parse::<f32>()
|
||||
.map_err(|_| format!("{flag} 需要数字,收到 {raw:?}"))
|
||||
.and_then(|value| {
|
||||
if value.is_finite() && value > 0.0 {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(format!("{flag} 必须是正数,收到 {raw:?}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn flavor(raw: &str) -> Result<SchemeFlavor, String> {
|
||||
match raw {
|
||||
"light" => Ok(SchemeFlavor::Light),
|
||||
"dark" => Ok(SchemeFlavor::Dark),
|
||||
other => Err(format!("主题只认 light|dark,收到 {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动(阻塞到出图或关窗)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 后端探测/字体求值失败、截图落盘失败时返回人话。
|
||||
pub fn run(request: Request) -> Result<(), String> {
|
||||
let started = Instant::now();
|
||||
|
||||
let needs_probe = request.backend == Backend::Auto;
|
||||
let probe = if needs_probe {
|
||||
Probe::run()
|
||||
} else {
|
||||
Probe::default()
|
||||
};
|
||||
probe::apply(request.backend, &probe);
|
||||
|
||||
let resolved = fonts::resolve();
|
||||
eprintln!("{}", resolved.describe());
|
||||
let families = resolved.families();
|
||||
|
||||
let boot = request.clone();
|
||||
let application = iced::application(
|
||||
move || {
|
||||
(
|
||||
Harness {
|
||||
ui: Ui::new(boot.flavor, families),
|
||||
request: boot.clone(),
|
||||
window: None,
|
||||
frames: 0,
|
||||
},
|
||||
Task::done(Message::Boot),
|
||||
)
|
||||
},
|
||||
Harness::update,
|
||||
Harness::view,
|
||||
)
|
||||
.theme(Harness::theme)
|
||||
.subscription(Harness::subscription)
|
||||
.default_font(iced::Font {
|
||||
family: iced::font::Family::Name(families.text),
|
||||
..iced::Font::DEFAULT
|
||||
})
|
||||
.window(window::Settings {
|
||||
size: iced::Size::new(request.width, request.height),
|
||||
decorations: false,
|
||||
resizable: true,
|
||||
..window::Settings::default()
|
||||
});
|
||||
|
||||
eprintln!(
|
||||
"[harness] screen={} width={} height={} slug={}(布局档位 {})",
|
||||
request.screen.slug(),
|
||||
request.width,
|
||||
request.height,
|
||||
request.slug(),
|
||||
super::layout::WidthClass::of(request.width).name()
|
||||
);
|
||||
|
||||
match request.scale_factor {
|
||||
Some(scale) => application.scale_factor(move |_| scale).run(),
|
||||
None => application.run(),
|
||||
}
|
||||
.map_err(|error| format!("iced 启动失败:{error}"))?;
|
||||
|
||||
eprintln!(
|
||||
"[harness] 用时 {:.0} ms",
|
||||
started.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
request: Request,
|
||||
ui: Ui,
|
||||
window: Option<window::Id>,
|
||||
frames: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Message {
|
||||
Boot,
|
||||
WindowReady(Option<window::Id>),
|
||||
Frame,
|
||||
Captured(window::Screenshot),
|
||||
/// 页面里的动作(骨架期只打印,接线后由 app 层处理)。
|
||||
Page(super::Message),
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Boot => window::latest().map(Message::WindowReady),
|
||||
Message::WindowReady(id) => {
|
||||
self.window = id;
|
||||
Task::none()
|
||||
}
|
||||
Message::Frame => {
|
||||
self.frames += 1;
|
||||
if self.frames < CAPTURE_AT_FRAME || self.request.screenshot.is_none() {
|
||||
return Task::none();
|
||||
}
|
||||
match self.window {
|
||||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
Message::Captured(screenshot) => {
|
||||
let Some(path) = self.request.screenshot.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
match shot::save(&screenshot, &path) {
|
||||
Ok(summary) => {
|
||||
eprintln!("{}", summary.describe());
|
||||
eprintln!("[harness] slug={}", self.request.slug());
|
||||
}
|
||||
Err(error) => eprintln!("[harness] 截图失败:{error}"),
|
||||
}
|
||||
iced::exit()
|
||||
}
|
||||
Message::Page(message) => {
|
||||
eprintln!("[harness] 页面动作:{message:?}(骨架期不做业务)");
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
if self.request.screenshot.is_some() {
|
||||
window::frames().map(|_| Message::Frame)
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn theme(&self) -> iced::Theme {
|
||||
self.ui.theme()
|
||||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
demo(&self.ui, self.request.screen, self.request.width).map(Message::Page)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(args: &[&str]) -> Result<Request, String> {
|
||||
Request::parse(args.iter().map(|arg| (*arg).to_owned()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_the_acceptance_frame() {
|
||||
let request = parse(&[]).expect("空参数要能解析");
|
||||
assert_eq!(request.screen, Screen::Instances);
|
||||
assert_eq!((request.width, request.height), (1180.0, 760.0));
|
||||
assert_eq!(request.flavor, SchemeFlavor::Light);
|
||||
assert!(request.screenshot.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_and_width_parse_in_both_argv_forms() {
|
||||
let spaced = parse(&["--screen", "settings", "--width", "900"]).unwrap();
|
||||
let inline = parse(&["--screen=settings", "--width=900"]).unwrap();
|
||||
assert_eq!(spaced, inline);
|
||||
assert_eq!(spaced.screen, Screen::Settings);
|
||||
assert_eq!(spaced.width, 900.0);
|
||||
assert!(parse(&["--screen=gallery"]).is_err());
|
||||
assert!(parse(&["--width=abc"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_theme_wins_over_theme() {
|
||||
let request = parse(&["--theme=dark", "--screenshot-theme=light"]).unwrap();
|
||||
assert_eq!(request.flavor, SchemeFlavor::Light);
|
||||
assert_eq!(parse(&["--theme=dark"]).unwrap().flavor, SchemeFlavor::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_renderer_is_an_alias_of_cpu() {
|
||||
assert_eq!(
|
||||
parse(&["--software-renderer"]).unwrap().backend,
|
||||
Backend::Cpu
|
||||
);
|
||||
assert_eq!(parse(&["--renderer=cpu"]).unwrap().backend, Backend::Cpu);
|
||||
assert_eq!(parse(&["--renderer=gpu"]).unwrap().backend, Backend::Gpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_carries_screen_class_and_flavor() {
|
||||
let request = parse(&[
|
||||
"--screen=instances",
|
||||
"--width=700",
|
||||
"--screenshot-theme=dark",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(request.slug(), "instances-medium-dark-w700");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_windows_are_rejected() {
|
||||
assert!(parse(&["--width=200"]).is_err());
|
||||
assert!(parse(&["--height=100"]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! 实例页:**list-detail** 的唯一用例。
|
||||
//!
|
||||
//! ## 版式(由 [`super::layout`] 决议,页面不自己算宽度)
|
||||
//!
|
||||
//! | 版面 | 结构 | 退化行为 |
|
||||
//! |---|---|---|
|
||||
//! | `ListDetail`(窗口 ≥840) | 左列 336 固定、**独立滚动**;右列详情,正文列 ≤424 | — |
|
||||
//! | `SingleColumn`(<840) | 列表与详情**整页切换**:本页只渲染详情列(≤424,居中) | 顶部给「Back to instances」,列表退成上一屏 |
|
||||
//!
|
||||
//! 为什么窄窗不是「上下堆」:官方 compact 下 list-detail 的口径是一次只显示一个 pane(列表 → 详情,
|
||||
//! 整页切换)。上下堆会把详情挤到折叠线以下,等于详情不存在。
|
||||
//!
|
||||
//! ## 判据
|
||||
//!
|
||||
//! 列表行 `Component::ListItem`(BodyLarge / BodySmall,圆角 `Small`),行高 = `row_height + Space::X3`
|
||||
//! = 56(两行行文;`row_height` 44 是单行列表的节奏)。选中行 `SecondaryContainer` + full 圆角(M3 列表)。
|
||||
|
||||
mod data;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(super) use data::SELECTED;
|
||||
use data::{Entry, DEMO, NOTE};
|
||||
|
||||
use iced::widget::{column, container, row, scrollable};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding};
|
||||
|
||||
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;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::frame::{self, Header};
|
||||
use super::layout::{self, FrameMetrics, PageLayout};
|
||||
use super::{Action, Message};
|
||||
|
||||
/// 整页(`selected = None` 时详情区显示空态)。
|
||||
pub fn view<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
selected: Option<usize>,
|
||||
) -> Element<'a, Message> {
|
||||
let header = Header::new("Instances")
|
||||
.subtitle("Local versions and their mod loaders")
|
||||
.action(frame::text_action(ui, "New instance", Action::NewInstance))
|
||||
.action(frame::text_action(ui, "Import", Action::Import));
|
||||
|
||||
let body = match metrics.layout {
|
||||
PageLayout::ListDetail => {
|
||||
let detail = match selected {
|
||||
Some(_) => detail(ui, metrics),
|
||||
None => detail_empty(ui),
|
||||
};
|
||||
frame::panes(metrics, list_column(ui, selected), detail)
|
||||
}
|
||||
_ => match selected {
|
||||
Some(_) => narrow_detail(ui, metrics),
|
||||
None => frame::single_column(metrics, detail_empty(ui)),
|
||||
},
|
||||
};
|
||||
|
||||
frame::page(ui, metrics, header, body)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 列表列
|
||||
|
||||
fn list_column<'a>(ui: &'a Ui, selected: Option<usize>) -> Element<'a, Message> {
|
||||
let head = container(
|
||||
row![
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
"ALL INSTANCES",
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
"12",
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(Padding {
|
||||
top: ui.space(Space::X3),
|
||||
right: ui.space(Space::X3),
|
||||
bottom: ui.space(Space::X1),
|
||||
left: ui.space(Space::X3),
|
||||
});
|
||||
|
||||
let rows = column(
|
||||
DEMO.iter()
|
||||
.enumerate()
|
||||
.map(|(index, entry)| list_row(ui, entry, selected == Some(index)))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.padding(Padding::from([0.0, ui.space(Space::X2)]))
|
||||
.width(Length::Fill);
|
||||
|
||||
// **独立滚动**:只有列表列这一条 scrollable,详情列不跟着滚。
|
||||
let scroller = scrollable(rows)
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(frame::SCROLLBAR_WIDTH)
|
||||
.scroller_width(frame::SCROLLBAR_WIDTH)
|
||||
.margin(frame::SCROLLBAR_MARGIN),
|
||||
))
|
||||
.height(Length::Fill)
|
||||
.style(frame::scroll_style(ui));
|
||||
|
||||
container(column![head, scroller].height(Length::Fill))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||||
border: Border {
|
||||
radius: ui.radius_dp(ShapeScale::Large),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn list_row<'a>(ui: &'a Ui, entry: &'a Entry, selected: bool) -> Element<'a, Message> {
|
||||
let spec = Component::ListItem.spec();
|
||||
let height = ui.metrics().row_height + Space::X3.dp();
|
||||
|
||||
let name_color = if selected {
|
||||
ui.color(Role::OnSecondaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnSurface)
|
||||
};
|
||||
let support_color = if selected {
|
||||
ui.color(Role::OnSecondaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
let texts = column![
|
||||
frame::text_role(ui, spec.text, false, entry.name, name_color),
|
||||
frame::text_role(
|
||||
ui,
|
||||
spec.support.unwrap_or(TypeRole::BodySmall),
|
||||
false,
|
||||
entry.loader,
|
||||
support_color,
|
||||
),
|
||||
]
|
||||
.spacing(0.0)
|
||||
.width(Length::Fill);
|
||||
|
||||
let inner = row![
|
||||
frame::glyph_box(IconKind::Instances, support_color, frame::ICON_PX),
|
||||
texts,
|
||||
frame::glyph(IconKind::ChevronRight, support_color, frame::ICON_SMALL_PX),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
container(inner)
|
||||
.width(Length::Fill)
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X2)]))
|
||||
.align_y(Alignment::Center)
|
||||
.style(move |_theme| container::Style {
|
||||
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, height),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 详情列
|
||||
|
||||
fn detail<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
|
||||
let entry = &DEMO[SELECTED];
|
||||
|
||||
frame::left_aligned(
|
||||
metrics.column,
|
||||
column![
|
||||
detail_head(ui, entry),
|
||||
meta_grid(ui, metrics, entry),
|
||||
frame::divider(ui),
|
||||
body_paragraph(ui),
|
||||
frame::divider(ui),
|
||||
action_row(ui),
|
||||
]
|
||||
.spacing(ui.space(Space::X4))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
}
|
||||
|
||||
fn narrow_detail<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
|
||||
// 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。
|
||||
let entry = &DEMO[SELECTED];
|
||||
|
||||
frame::single_column(
|
||||
metrics,
|
||||
column![
|
||||
row![frame::text_action(ui, "Back to instances", Action::Back)]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
detail_head(ui, entry),
|
||||
meta_grid(ui, metrics, entry),
|
||||
frame::divider(ui),
|
||||
body_paragraph(ui),
|
||||
frame::divider(ui),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
"Below 840 px the list and the detail share one column, so opening an instance replaces \
|
||||
this screen (full-page switch). Widen the window to see both panes again.",
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
action_row(ui),
|
||||
]
|
||||
.spacing(ui.space(Space::X4))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
}
|
||||
|
||||
fn detail_head<'a>(ui: &'a Ui, entry: &'a Entry) -> Element<'a, Message> {
|
||||
let titles = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::TitleLarge,
|
||||
true,
|
||||
entry.name,
|
||||
ui.color(Role::OnSurface)
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
entry.version,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
row![
|
||||
titles,
|
||||
frame::text_action(ui, "Play", Action::Play),
|
||||
frame::text_action(ui, "More", Action::More),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 元信息网格:列数由 [`layout::meta_columns`](按正文列宽)决定 —— **1180 与 900 都是 2 列**。
|
||||
///
|
||||
/// 3 列需要 `3 × META_CARD_MIN_WIDTH + 2 × META_GAP` = 504px,而行宽上限是 424,**数学上不可行**;
|
||||
/// 硬塞回 3 列会让 `NeoForge 20.4.237`(118px)这类值按词折行、同排卡底不齐。
|
||||
fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, entry: &'a Entry) -> Element<'a, Message> {
|
||||
let cells = [
|
||||
("VERSION", entry.version),
|
||||
("LOADER", entry.loader),
|
||||
("MEMORY", entry.memory),
|
||||
("JAVA", entry.java),
|
||||
("MODS", entry.mods),
|
||||
("LAST PLAYED", entry.last_played),
|
||||
];
|
||||
|
||||
let mut grid = column![].spacing(layout::META_GAP).width(Length::Fill);
|
||||
let columns = metrics.meta_columns.clamp(1, cells.len());
|
||||
|
||||
for chunk in cells.chunks(columns) {
|
||||
let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill);
|
||||
for (label, value) in chunk {
|
||||
let cell = frame::card(
|
||||
ui,
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
label,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
value,
|
||||
ui.color(Role::OnSurface)
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
);
|
||||
cells_row = cells_row.push(container(cell).width(Length::Fill));
|
||||
}
|
||||
grid = grid.push(cells_row);
|
||||
}
|
||||
|
||||
grid.into()
|
||||
}
|
||||
|
||||
fn body_paragraph<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
NOTE,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
|
||||
fn action_row<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
row![
|
||||
frame::text_action(ui, "Duplicate", Action::Duplicate),
|
||||
frame::text_action(ui, "Open folder", Action::OpenFolder),
|
||||
frame::text_action(ui, "Delete", Action::Delete),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn detail_empty<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
frame::empty_state(
|
||||
ui,
|
||||
IconKind::Instances,
|
||||
"No instance selected",
|
||||
"Pick an instance on the left to see its version, loader and launch options.",
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! 实例页的**演示数据**(骨架期没有业务状态机,也没有业务数据)。
|
||||
//!
|
||||
//! 12 条,**故意比可视区高**:列表列的独立滚动条要能在截图里看见(单测钉住这条)。
|
||||
//! 文案是英文基线:i18n 是后续 mission,干净 CI 上没有中文字体,写中文会变豆腐块。
|
||||
|
||||
/// 演示条目(骨架期没有业务数据;文案是英文基线,i18n 是后续 mission)。
|
||||
pub(crate) struct Entry {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) version: &'static str,
|
||||
pub(crate) loader: &'static str,
|
||||
pub(crate) memory: &'static str,
|
||||
pub(crate) java: &'static str,
|
||||
pub(crate) mods: &'static str,
|
||||
pub(crate) last_played: &'static str,
|
||||
}
|
||||
|
||||
/// 12 条演示数据:**比可视区高**,这样列表列的独立滚动条在截图里能看见。
|
||||
pub(crate) const DEMO: [Entry; 12] = [
|
||||
Entry {
|
||||
name: "Fabulous World",
|
||||
version: "1.20.4",
|
||||
loader: "Fabric 0.15.7",
|
||||
memory: "4 GiB",
|
||||
java: "Temurin 21",
|
||||
mods: "148 mods",
|
||||
last_played: "2 hours ago",
|
||||
},
|
||||
Entry {
|
||||
name: "Skyblock Origins",
|
||||
version: "1.21.1",
|
||||
loader: "NeoForge 21.1.72",
|
||||
memory: "6 GiB",
|
||||
java: "Temurin 21",
|
||||
mods: "86 mods",
|
||||
last_played: "yesterday",
|
||||
},
|
||||
Entry {
|
||||
name: "Vanilla Survival",
|
||||
version: "1.21.4",
|
||||
loader: "Vanilla",
|
||||
memory: "2 GiB",
|
||||
java: "Temurin 21",
|
||||
mods: "0 mods",
|
||||
last_played: "3 days ago",
|
||||
},
|
||||
Entry {
|
||||
name: "Create: Above and Beyond",
|
||||
version: "1.18.2",
|
||||
loader: "Forge 40.2.0",
|
||||
memory: "8 GiB",
|
||||
java: "Temurin 17",
|
||||
mods: "312 mods",
|
||||
last_played: "last week",
|
||||
},
|
||||
Entry {
|
||||
name: "All the Mods 9",
|
||||
version: "1.20.1",
|
||||
loader: "NeoForge 20.4.237",
|
||||
memory: "8 GiB",
|
||||
java: "Temurin 17",
|
||||
mods: "412 mods",
|
||||
last_played: "2 weeks ago",
|
||||
},
|
||||
Entry {
|
||||
name: "Better MC",
|
||||
version: "1.20.1",
|
||||
loader: "Fabric 0.14.24",
|
||||
memory: "6 GiB",
|
||||
java: "Temurin 17",
|
||||
mods: "264 mods",
|
||||
last_played: "last month",
|
||||
},
|
||||
Entry {
|
||||
name: "RLCraft",
|
||||
version: "1.12.2",
|
||||
loader: "Forge 14.23.5",
|
||||
memory: "4 GiB",
|
||||
java: "Zulu 8",
|
||||
mods: "184 mods",
|
||||
last_played: "2 months ago",
|
||||
},
|
||||
Entry {
|
||||
name: "Origins SMP",
|
||||
version: "1.20.4",
|
||||
loader: "Quilt 0.24.0",
|
||||
memory: "4 GiB",
|
||||
java: "Temurin 21",
|
||||
mods: "57 mods",
|
||||
last_played: "3 months ago",
|
||||
},
|
||||
Entry {
|
||||
name: "Redstone Lab",
|
||||
version: "1.19.4",
|
||||
loader: "Fabric 0.15.2",
|
||||
memory: "3 GiB",
|
||||
java: "Temurin 17",
|
||||
mods: "34 mods",
|
||||
last_played: "April 12",
|
||||
},
|
||||
Entry {
|
||||
name: "Pixelmon Reforged",
|
||||
version: "1.16.5",
|
||||
loader: "Forge 36.2.34",
|
||||
memory: "6 GiB",
|
||||
java: "Temurin 8",
|
||||
mods: "121 mods",
|
||||
last_played: "March 3",
|
||||
},
|
||||
Entry {
|
||||
name: "Techopolis 2",
|
||||
version: "1.20.1",
|
||||
loader: "Forge 47.2.20",
|
||||
memory: "8 GiB",
|
||||
java: "Temurin 17",
|
||||
mods: "198 mods",
|
||||
last_played: "January 28",
|
||||
},
|
||||
Entry {
|
||||
name: "Cobblemon",
|
||||
version: "1.21.1",
|
||||
loader: "Fabric 0.16.9",
|
||||
memory: "5 GiB",
|
||||
java: "Temurin 21",
|
||||
mods: "74 mods",
|
||||
last_played: "January 9",
|
||||
},
|
||||
];
|
||||
|
||||
/// 演示选中的条目(第一条)。
|
||||
pub(crate) const SELECTED: usize = 0;
|
||||
|
||||
/// 详情正文(**故意写长**:这是行宽上限的视觉证据——1180 宽窗口下也只断在 ~72 字符)。
|
||||
pub(crate) const NOTE: &str = "This instance keeps its own mods, configs and saves. The launcher resolves the version chain \
|
||||
(instance → mod loader → vanilla) at launch time, verifies every jar it downloaded against the mirror \
|
||||
manifest, and streams progress back as structured events so the UI never has to guess. Nothing on this \
|
||||
page is wired to real data yet: the layout is, the numbers are.";
|
||||
@@ -0,0 +1,84 @@
|
||||
//! `instances` 的断言:演示数据与窄窗退化。
|
||||
use super::*;
|
||||
|
||||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
||||
|
||||
#[test]
|
||||
fn demo_rows_overflow_the_visible_list() {
|
||||
// 列表列要能滚动:条目数必须让内容高过可视区(760 窗高 − 标题栏 48 − 页头 84 − 页边距)
|
||||
let visible = 760.0 - 48.0 - 84.0 - 2.0 * WidthClass::Expanded.gutter();
|
||||
let row_height = 44.0 + Space::X3.dp();
|
||||
let content = DEMO.len() as f32 * row_height;
|
||||
assert!(
|
||||
content > visible,
|
||||
"列表内容 {content} 不比可视区 {visible} 高,截图里看不到独立滚动条"
|
||||
);
|
||||
}
|
||||
|
||||
/// 元信息卡必须装得下**卡内最长的那条值**(视觉 r2:136 漏算 padding,版本串折行让卡底不齐)。
|
||||
#[test]
|
||||
fn meta_card_is_wide_enough_for_the_longest_value() {
|
||||
use super::super::layout::{measure_cap_px, META_CARD_MIN_WIDTH, META_GAP};
|
||||
use unlucky_design::spacing::LayoutMetrics;
|
||||
|
||||
// 实测(Segoe UI 14px):demo 数据里最宽的元信息值是加载器版本串
|
||||
const LONGEST_VALUE: &str = "NeoForge 20.4.237";
|
||||
const LONGEST_VALUE_PX: f32 = 118.0;
|
||||
|
||||
let padding = LayoutMetrics::DESKTOP.card_padding;
|
||||
assert_eq!(padding, 20.0, "卡内边距来自令牌");
|
||||
|
||||
// 夹具与 demo 数据不许脱节:这个值必须真在 DEMO 里
|
||||
assert!(
|
||||
DEMO.iter().any(|entry| entry.loader == LONGEST_VALUE),
|
||||
"夹具 {LONGEST_VALUE:?} 已经不在 DEMO 里了,请重新量最长值"
|
||||
);
|
||||
|
||||
// ① 单卡外框减掉两侧内边距,必须放得下这条值(否则 text 会按词折行 → 卡底不齐)
|
||||
let inner = META_CARD_MIN_WIDTH - 2.0 * padding;
|
||||
assert!(
|
||||
inner >= LONGEST_VALUE_PX,
|
||||
"卡内可用 {inner}px 装不下 {LONGEST_VALUE_PX}px 的 {LONGEST_VALUE:?}(外框 {META_CARD_MIN_WIDTH} − 2×{padding})"
|
||||
);
|
||||
|
||||
// ② 实际版面里每张卡的内宽(2 列时)也要够
|
||||
let two_columns = (measure_cap_px() - META_GAP) / 2.0;
|
||||
assert!(
|
||||
two_columns - 2.0 * padding >= LONGEST_VALUE_PX,
|
||||
"两列布局下卡内可用 {}px 装不下 {LONGEST_VALUE_PX}px",
|
||||
two_columns - 2.0 * padding
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_demo_entry_has_name_version_and_loader() {
|
||||
for entry in &DEMO {
|
||||
assert!(!entry.name.is_empty());
|
||||
assert!(!entry.version.is_empty());
|
||||
assert!(!entry.loader.is_empty());
|
||||
}
|
||||
assert!(SELECTED < DEMO.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_paragraph_is_long_enough_to_wrap() {
|
||||
// 行宽上限 ≈ 72 字符/行;写够几行才看得出断行位置
|
||||
assert!(NOTE.len() > 200, "正文档太短,看不出行宽上限");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_widths_switch_the_page_to_one_column() {
|
||||
// 1180 / 900 → 两列;700 → 单列(整页切换)
|
||||
assert_eq!(
|
||||
frame_metrics(PaneKit::ListDetail, 1180.0).layout,
|
||||
PageLayout::ListDetail
|
||||
);
|
||||
assert_eq!(
|
||||
frame_metrics(PaneKit::ListDetail, 700.0).layout,
|
||||
PageLayout::SingleColumn
|
||||
);
|
||||
// 窄窗下详情不吃满窗口:正文列仍 ≤ 行宽上限
|
||||
let narrow = frame_metrics(PaneKit::ListDetail, 700.0);
|
||||
assert!(narrow.column <= measure_cap_px() + f32::EPSILON);
|
||||
assert!(narrow.secondary.is_none());
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
//! 页面布局系统:**窗口宽度档位 → 版面 → 列宽**。纯函数,不碰 iced 控件树。
|
||||
//!
|
||||
//! 这是本 mission 的核心交付物,四个页面只是它的用例。`layout.rs` 是页面级几何的
|
||||
//! **唯一定义点**:页面文件里不许再出现列宽字面量。
|
||||
//!
|
||||
//! ## 1. 宽度档位(官方响应式断点 600 / 840)
|
||||
//!
|
||||
//! 档位定义照官方 M3「Window size classes」(A 级:m3.material.io 的 app-quality/window-size-classes,
|
||||
//! Compose `WindowSizeClass` 的同一组硬值):**600dp 与 840dp** 是两个分界。
|
||||
//!
|
||||
//! | 档位 | 窗口宽度(逻辑 px @100% DPI) | 页边距(gutter) |
|
||||
//! |---|---|---|
|
||||
//! | `Compact` | `< 600` | 16(`Space::X4`) |
|
||||
//! | `Medium` | `600 ≤ w < 840` | 24(`Space::X6`) |
|
||||
//! | `Expanded` | `≥ 840` | 24(`Space::X6`) |
|
||||
//!
|
||||
//! 页边距取 16/24 而不是 16/24/32:官方 expanded 页边距也是 24,写 32 会白白多一条偏离登记。
|
||||
//!
|
||||
//! > 本 crate 的真实窗口下限是 880(`app.rs` 的 `MIN_SIZE`),所以日常只有 `Expanded`;
|
||||
//! > 600 以下的档位是给「将来允许更窄窗口 / 内嵌面板」准备的,也让布局规则**可测**。
|
||||
//!
|
||||
//! ## 2. 三种版面与「什么宽度用哪种」
|
||||
//!
|
||||
//! | 版面 | 含义 | 页面上用它 | 成立条件 | 退化到 |
|
||||
//! |---|---|---|---|---|
|
||||
//! | `SingleColumn` | 单列,列宽 ≤ 行宽上限 | 下载 / 日志(全部档位);实例 / 设置(窄窗) | 总是成立 | — |
|
||||
//! | `SupportingPane` | 主列 + 窄侧列(侧列是辅助,主列是阅读对象) | 设置(分组导航 240 + 表单列) | `w ≥ 600` 且 `w ≥ 720` | `SingleColumn` |
|
||||
//! | `ListDetail` | 固定列表列(320~360,**独立滚动**)+ 详情列 | 实例 | `w ≥ 840` 且 `w ≥ 816` | `SingleColumn` |
|
||||
//!
|
||||
//! 两个门槛都是**算出来的**,不是拍的(见 [`panes_min_window`]):
|
||||
//!
|
||||
//! - 设置:`240(drawer)+ 24(间隙)+ 320(表单列下限)+ 2×24(gutter)+ 88(rail)= 720`;
|
||||
//! - 实例:`336(列表列)+ 24(间隙)+ 320(详情列下限)+ 2×24(gutter)+ 88(rail)= 816`。
|
||||
//!
|
||||
//! 实例的几何门槛 816 低于官方 expanded 断点 840 —— 所以**档位是更严的那一条**,
|
||||
//! 单测 `expanded_is_wide_enough_for_list_detail` 把这条「余量 24px」钉死。
|
||||
//!
|
||||
//! ## 3. 行宽约束(历史事故:M68 的全宽单卡)
|
||||
//!
|
||||
//! M68 骨架的内容区是**一张全宽卡**:卡片横跨 x=112..1156(**1044px**),卡片内文本区 **1004px**
|
||||
//! (边距 24 + 卡片内边距 20)。那张卡里的正文是 `BodySmall` 12px:步进 = 14px 实测值
|
||||
//! 6.0526 × 12/14 = **5.19px/字符**,所以 137 字符 × 5.19 ≈ **711px** —— 与
|
||||
//! `DevDocs/ops/screens-m68/shell-light-cpu.png` 里那一行**实测 ink 712px**(x=133..845)吻合。
|
||||
//! 视觉复审的判词因此是「**不能把全宽单卡当页面模板**,官方桌面口径是 supporting-pane / list-detail」。
|
||||
//!
|
||||
//! > **两个订正记录**(都是 P2 复审抓出来的):
|
||||
//! > ① **712 是那一行的墨迹宽度,不是列宽**。早期文档写成「1180 − 88 − 2×24 = 712」,
|
||||
//! > 那个减法得 1044,712 是 ink——列宽是 1004,ink 只占其中的 71%。
|
||||
//! > ② **字符数按实测算**:那一行是 **137 字符**(12px × 0.4323em = 5.19px/字符)。
|
||||
//! > 早期口径拿 `0.5em` 估算的「14px 正文步进」换算,既高估步进、也对错了字号,把事故说轻了。
|
||||
//! > 现在事故描述一律用**实测步进 × 实际字号**;这也是
|
||||
//! > `line_length_is_capped_at_72_characters` 里那条「旧口径 504px 能放 83 字符」护栏的由来。
|
||||
//!
|
||||
//! ### 逐行口径:72 字符是**最长一行**的上界,不是平均(r2 复审的教训)
|
||||
//!
|
||||
//! 「平均步进 × 72」只能保证**平均**一行 72 字符:r2 复审在交付图里抓到一行 **73 字符**
|
||||
//! (`guess. Nothing on this page is wired to real data yet: the layout is, the`,ink 428px)。
|
||||
//! 所以上限**不是算术结果,而是逐行折行模拟的结果**——用 FreeType 量宽 + 贪心词折行,
|
||||
//! 对实例页那段正文(361 字符)扫不同列宽:
|
||||
//!
|
||||
//! | 列宽 | 最长一行 | 该行 ink | 结论 |
|
||||
//! |---|---|---|---|
|
||||
//! | 435(= 平均口径上界) | 73 字符 | 428px | ✗ 破了 72 |
|
||||
//! | 428 | 73 字符 | 428px | ✗ |
|
||||
//! | 426 | 71 字符 | 426px | ✓ 但只剩 1 字符余量 |
|
||||
//! | **424**(4px 栅格) | **67 字符** | **402px** | ✓ 取这个:满足上界 + 留 5 字符余量 |
|
||||
//!
|
||||
//! 于是 [`measure_cap_px`] = **424**(= `min(逐行安全宽 424, 平均口径上界 435)`),由
|
||||
//! [`content_column_width`] 与 [`frame_metrics`] 无条件施加:任何窗口宽度、任何版面,
|
||||
//! 正文列都不会超过它。1180 宽窗口下正文列是 **424**(不是 712)。
|
||||
//!
|
||||
//! **作用域**:这条承诺只覆盖 `BodyMedium` 14px 的正文。`BodySmall` 12px 的辅助文案
|
||||
//! (空态副文案、窄窗提示)在同一列宽下每行可到 ~82 字符——它们都是 1~2 行的短句,不做逐行承诺;
|
||||
//! 中文(全角 1em)另算,等 i18n mission 定。
|
||||
|
||||
//!
|
||||
//! ### 步进是怎么量的(口径 + 可复现)
|
||||
//!
|
||||
//! - **测的是哪组字形**:`BodyMedium` 的字体栈(本机解析到 Segoe UI **Regular**,14px),
|
||||
//! **无 letter-spacing**——令牌的 0.5px 字距 iced 0.14 不支持(已登记的令牌丢失),渲染里没有;
|
||||
//! - **样本**:实例页详情那段正文(`pages/instances/data.rs` 的 `NOTE`,**361 字符**,
|
||||
//! 大小写 / 标点 / 空格 / `→` 混排,是我们自己的真实正文,不是造出来的 `mmmm`);
|
||||
//! - **量法**:FreeType `getlength`(PIL `ImageFont.truetype(segoeui.ttf, 14)`)求整段排版宽,
|
||||
//! 再除以字符数 → **2185.00px / 361 = 6.0526 px/字符 = 0.43233em**;× 72 = 435.79(这是**平均**口径上界,逐行口径见下);
|
||||
//! - **交叉验证**:同一段的首行(78 字符)渲染实测墨迹 **484px**,FreeType 对同一串给 479px
|
||||
//! (差 ~1%,抗锯齿外扩),说明「步进」这个口径在 iced 渲染器与 FreeType 之间是一致的;
|
||||
//! - **CJK 不适用**:全角字形是 1em(14px),中文的 72 字符是 1008px。页面文案走 i18n 之后
|
||||
//! 要按语言分别定上限,这条留给 i18n mission;本模块只保证英文基线。
|
||||
|
||||
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
/// 官方 M3 窗口档的 medium 断点(600dp)。
|
||||
pub const BREAKPOINT_MEDIUM: f32 = 600.0;
|
||||
/// 官方 M3 窗口档的 expanded 断点(840dp)。
|
||||
pub const BREAKPOINT_EXPANDED: f32 = 840.0;
|
||||
|
||||
/// 列表列宽(list-detail 左列)。任务区间 320~360,取 4px 栅格内的 **336**:
|
||||
/// 落在区间中段,既放得下「名字 + 版本 + 尾随图标」,又给详情列多留 16px。
|
||||
pub const LIST_COLUMN_WIDTH: f32 = 336.0;
|
||||
|
||||
/// 详情列下限:再窄就不再分栏。
|
||||
///
|
||||
/// 按实测步进算:45 字符 × 6.05px ≈ 272,取 4px 栅格并留余量 → **320**
|
||||
/// (320 / 6.05 = 53 字符,仍在行宽下限之上)。
|
||||
pub const DETAIL_MIN_WIDTH: f32 = 320.0;
|
||||
|
||||
/// 侧列宽(设置页分组导航)= D33 的 drawer 宽 240。
|
||||
///
|
||||
/// 复用 rail 的**尺寸语义**(88 ⇄ 240 同一容器的两种形态),但**不复制 rail 的实现**:
|
||||
/// 分组导航是页内元素,没有 hover 药丸、没有二级标签。
|
||||
pub const SIDE_NAV_WIDTH: f32 = LayoutMetrics::DESKTOP.nav_drawer_width;
|
||||
|
||||
/// 表单列下限(supporting-pane 主列):与详情列下限同源(320 ≈ 45 字符)。
|
||||
pub const FORM_MIN_WIDTH: f32 = DETAIL_MIN_WIDTH;
|
||||
|
||||
/// 两列之间的间隙(`Space::X6` = 24)。
|
||||
pub const PANES_GAP: f32 = Space::X6.dp();
|
||||
|
||||
/// 详情里元信息网格的**单卡最小宽度 = 由卡内最长内容反推**:**160**。
|
||||
///
|
||||
/// 推导(不是凑出来的):
|
||||
/// - 卡内最长值是 demo 里的 `NeoForge 20.4.237`,Segoe UI 14px 实测 **118px**;
|
||||
/// - 卡两侧内边距 `LayoutMetrics::card_padding` = 20,故最小外框 = 118 + 2×20 = **158**;
|
||||
/// - 取 4px 栅格的 **160**(等价于旧值,但这次是**内容推出来的**,不是凑上限凑出来的)。
|
||||
///
|
||||
/// r2 复审抓到的回归:上一轮为了塞三列把这里收到 136,忘了算 padding ——
|
||||
/// 卡内只剩 96px,`NeoForge 20.4.237`(118px)与 `NeoForge 21.1.72`(110px)都会折成两行,
|
||||
/// 同排另两张卡仍一行 → **卡底不齐**。160 之下 3 列要 3×160 + 2×12 = 504,超出行宽上限,
|
||||
/// 所以元信息网格在 1180 宽是 **2 列**(2×160 + 12 = 332,卡内可排 166px,装得下 118)。
|
||||
/// 这也是有意的:mod 加载器版本串只会更长,2 列宽卡比 3 列挤卡更抗将来。
|
||||
pub const META_CARD_MIN_WIDTH: f32 = 160.0;
|
||||
/// 元信息网格卡片间距(`Space::X3` = 12)。
|
||||
pub const META_GAP: f32 = Space::X3.dp();
|
||||
|
||||
/// 正文平均字符步进(em)——**实测值**,口径见模块文档 §3。
|
||||
///
|
||||
/// `BodyMedium` 14px / Segoe UI Regular / 361 字符英文混排正文,FreeType 实测
|
||||
/// **6.0526px/字符 = 0.43233em**。
|
||||
///
|
||||
/// 旧的 `0.5`(`[估]`)把 72 字符的列宽算成 504px,而实测 504px 能塞 **83 字符**——
|
||||
/// 「72 字符」这个名字和数字对不上的根因就在这里。换字体栈要重新量(这不是令牌)。
|
||||
pub const CHAR_ADVANCE_EM: f32 = 0.43233;
|
||||
/// 舒适行宽的字符数上限(任务建议 60~72,取上界 72)。
|
||||
pub const MAX_MEASURE_CHARS: f32 = 72.0;
|
||||
/// 行宽下限的字符数(再窄正文就碎成短行,45 是最低可读长度)。
|
||||
pub const MIN_MEASURE_CHARS: f32 = 45.0;
|
||||
|
||||
/// 导航轨宽度(与 `shell::nav` 同一令牌值;布局系统只读它,不重定义)。
|
||||
pub const fn rail_width() -> f32 {
|
||||
LayoutMetrics::DESKTOP.nav_rail_width
|
||||
}
|
||||
|
||||
/// **逐行**安全宽度(px):折行模拟下「最长一行 ≤72 字符」的最大 4px 栅格列宽 = **424**。
|
||||
///
|
||||
/// 见模块文档 §3 的模拟表:428 会出现 73 字符行,426 起降到 71,424 给到 67(留 5 字符余量)。
|
||||
/// 这个数是**量出来的**,不是 `advance × 72` 算出来的——后者只保证平均。
|
||||
pub const LINE_SAFE_WIDTH_PX: f32 = 424.0;
|
||||
|
||||
/// 平均口径上界(px):`floor(14px × 0.43233em × 72)` = **435**。
|
||||
///
|
||||
/// 只能保证**平均**一行 72 字符(实测 435 下会冒出 73 字符行),所以它是上界而不是取值。
|
||||
pub fn measure_average_bound_px() -> f32 {
|
||||
(TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MAX_MEASURE_CHARS).floor()
|
||||
}
|
||||
|
||||
/// 正文列宽上限(px)= `min(逐行安全宽 424, 平均口径上界 435)` = **424**。
|
||||
///
|
||||
/// 硬上限,任何版面 / 任何窗口宽度都不许超;`1180` 窗宽下正文列就是它(而非 M68 的 712)。
|
||||
pub fn measure_cap_px() -> f32 {
|
||||
LINE_SAFE_WIDTH_PX.min(measure_average_bound_px())
|
||||
}
|
||||
|
||||
/// 正文列宽下限(px):`floor(14 × 0.43233 × 45)` = **272**。
|
||||
pub fn measure_floor_px() -> f32 {
|
||||
(TypeRole::BodyMedium.style().size_px * CHAR_ADVANCE_EM * MIN_MEASURE_CHARS).floor()
|
||||
}
|
||||
|
||||
/// 窗口宽度档位(官方 600 / 840 两个断点)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WidthClass {
|
||||
/// `< 600`
|
||||
Compact,
|
||||
/// `600 ≤ w < 840`
|
||||
Medium,
|
||||
/// `≥ 840`
|
||||
Expanded,
|
||||
}
|
||||
|
||||
impl WidthClass {
|
||||
/// 按**窗口宽度**(含导航轨,逻辑 px)判档。
|
||||
pub fn of(window_width: f32) -> Self {
|
||||
if window_width < BREAKPOINT_MEDIUM {
|
||||
Self::Compact
|
||||
} else if window_width < BREAKPOINT_EXPANDED {
|
||||
Self::Medium
|
||||
} else {
|
||||
Self::Expanded
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定名字(日志/测试/文件名用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Compact => "compact",
|
||||
Self::Medium => "medium",
|
||||
Self::Expanded => "expanded",
|
||||
}
|
||||
}
|
||||
|
||||
/// 页边距:compact 16 / medium 24 / expanded 24(官方 expanded 也是 24)。
|
||||
pub const fn gutter(self) -> f32 {
|
||||
match self {
|
||||
Self::Compact => Space::X4.dp(),
|
||||
Self::Medium | Self::Expanded => Space::X6.dp(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 三种版面。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PageLayout {
|
||||
/// 单列(列宽 ≤ 行宽上限,水平居中)。
|
||||
SingleColumn,
|
||||
/// 主列 + 辅助侧列。
|
||||
SupportingPane,
|
||||
/// 固定列表列 + 详情列。
|
||||
ListDetail,
|
||||
}
|
||||
|
||||
impl PageLayout {
|
||||
/// 稳定名字。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::SingleColumn => "single-column",
|
||||
Self::SupportingPane => "supporting-pane",
|
||||
Self::ListDetail => "list-detail",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否有两个列(侧列 + 主列)。
|
||||
pub const fn is_two_pane(self) -> bool {
|
||||
!matches!(self, Self::SingleColumn)
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面声明的「最宽版面」——布局系统按窗口宽度降级。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaneKit {
|
||||
/// 单列页(下载 / 日志)。
|
||||
Single,
|
||||
/// 主列 + 侧列(设置)。
|
||||
Supporting,
|
||||
/// 列表 + 详情(实例)。
|
||||
ListDetail,
|
||||
}
|
||||
|
||||
/// 两列版面成立所需的最小窗口宽度(**公式**,不是拍值):
|
||||
/// `Σ列宽 + 间隙 + 2×gutter + rail`。
|
||||
pub fn panes_min_window(first: f32, second: f32) -> f32 {
|
||||
first + second + PANES_GAP + 2.0 * WidthClass::Expanded.gutter() + rail_width()
|
||||
}
|
||||
|
||||
/// `ListDetail` 的几何门槛:336 + 320 + 24 + 48 + 88 = **816**。
|
||||
pub fn list_detail_min_window() -> f32 {
|
||||
panes_min_window(LIST_COLUMN_WIDTH, DETAIL_MIN_WIDTH)
|
||||
}
|
||||
|
||||
/// `SupportingPane` 的几何门槛:240 + 320 + 24 + 48 + 88 = **720**。
|
||||
pub fn supporting_pane_min_window() -> f32 {
|
||||
panes_min_window(SIDE_NAV_WIDTH, FORM_MIN_WIDTH)
|
||||
}
|
||||
|
||||
/// 版面决议:档位与几何门槛**同时**满足才升到两列,否则退化单列。
|
||||
pub fn resolve(kit: PaneKit, window_width: f32) -> PageLayout {
|
||||
let class = WidthClass::of(window_width);
|
||||
let wide_enough = match kit {
|
||||
PaneKit::Single => false,
|
||||
PaneKit::Supporting => {
|
||||
class != WidthClass::Compact && window_width >= supporting_pane_min_window()
|
||||
}
|
||||
PaneKit::ListDetail => {
|
||||
class == WidthClass::Expanded && window_width >= list_detail_min_window()
|
||||
}
|
||||
};
|
||||
|
||||
match (kit, wide_enough) {
|
||||
(PaneKit::Single, _) | (_, false) => PageLayout::SingleColumn,
|
||||
(PaneKit::Supporting, true) => PageLayout::SupportingPane,
|
||||
(PaneKit::ListDetail, true) => PageLayout::ListDetail,
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面可用宽度:窗口 − 导航轨 − 两侧页边距(下限 0)。
|
||||
pub fn available_width(window_width: f32) -> f32 {
|
||||
let class = WidthClass::of(window_width);
|
||||
(window_width - rail_width() - 2.0 * class.gutter()).max(0.0)
|
||||
}
|
||||
|
||||
/// **给定窗口宽度 → 内容(正文)列宽**。本 mission 的「行宽约束」入口。
|
||||
///
|
||||
/// - 单列页:就是正文列宽(水平居中摆放);
|
||||
/// - 两列页:这是**主列**的正文列宽;实际值还要看 [`frame_metrics`](详情列会被列表列挤窄)。
|
||||
///
|
||||
/// 返回值永远 ≤ [`measure_cap_px`]:1180 窗宽下是 424(逐行 67 字符,≤72),不是 M68 的 712。
|
||||
pub fn content_column_width(window_width: f32) -> f32 {
|
||||
available_width(window_width).min(measure_cap_px())
|
||||
}
|
||||
|
||||
/// 元信息网格的列数:3 列需要 504(超出行宽上限,1180 下用不到),2 列需要 332,否则 1 列。
|
||||
pub fn meta_columns(width: f32) -> usize {
|
||||
for columns in [3usize, 2] {
|
||||
let needed = columns as f32 * META_CARD_MIN_WIDTH + (columns - 1) as f32 * META_GAP;
|
||||
if width >= needed {
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
1
|
||||
}
|
||||
|
||||
/// 一屏的版面几何(页面直接消费,不再自己算列宽)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FrameMetrics {
|
||||
/// 窗口宽度(输入)。
|
||||
pub window_width: f32,
|
||||
/// 宽度档位。
|
||||
pub class: WidthClass,
|
||||
/// 决议出来的版面。
|
||||
pub layout: PageLayout,
|
||||
/// 页边距。
|
||||
pub gutter: f32,
|
||||
/// 页面可用宽度(已扣导航轨与页边距)。
|
||||
pub available: f32,
|
||||
/// 正文列宽(≤ 行宽上限)。两列版面时是**主列**的正文列宽。
|
||||
pub column: f32,
|
||||
/// 第二列(list-detail 的列表列 / supporting-pane 的侧列)。
|
||||
pub secondary: Option<f32>,
|
||||
/// 主列所在 pane 的宽度(单列 = 正文列宽)。
|
||||
pub main_pane: f32,
|
||||
/// 详情元信息网格列数。
|
||||
pub meta_columns: usize,
|
||||
}
|
||||
|
||||
/// 一次算齐版面几何。
|
||||
pub fn frame_metrics(kit: PaneKit, window_width: f32) -> FrameMetrics {
|
||||
let class = WidthClass::of(window_width);
|
||||
let gutter = class.gutter();
|
||||
let available = available_width(window_width);
|
||||
let layout = resolve(kit, window_width);
|
||||
|
||||
let (column, secondary, main_pane) = match layout {
|
||||
PageLayout::SingleColumn => {
|
||||
let column = available.min(measure_cap_px());
|
||||
(column, None, column)
|
||||
}
|
||||
PageLayout::ListDetail => {
|
||||
let list = LIST_COLUMN_WIDTH.min(available);
|
||||
let pane = (available - list - PANES_GAP).max(0.0);
|
||||
(pane.min(measure_cap_px()), Some(list), pane)
|
||||
}
|
||||
PageLayout::SupportingPane => {
|
||||
let side = SIDE_NAV_WIDTH.min(available);
|
||||
let pane = (available - side - PANES_GAP).max(0.0);
|
||||
(pane.min(measure_cap_px()), Some(side), pane)
|
||||
}
|
||||
};
|
||||
|
||||
FrameMetrics {
|
||||
window_width,
|
||||
class,
|
||||
layout,
|
||||
gutter,
|
||||
available,
|
||||
column,
|
||||
secondary,
|
||||
main_pane,
|
||||
meta_columns: meta_columns(column),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,367 @@
|
||||
//! `layout` 的断言:断点表、版面决议、行宽上限、列宽几何。
|
||||
//!
|
||||
//! 这些是**门禁级**断言:阈值改错必须立刻红(tower 会故意改错来验)。
|
||||
use super::*;
|
||||
|
||||
/// 断点表:官方 600 / 840 两个硬值,边界闭开区间逐点钉死。
|
||||
#[test]
|
||||
fn breakpoints_are_the_official_600_and_840() {
|
||||
assert_eq!(BREAKPOINT_MEDIUM, 600.0, "官方 medium 断点");
|
||||
assert_eq!(BREAKPOINT_EXPANDED, 840.0, "官方 expanded 断点");
|
||||
|
||||
for (width, expected) in [
|
||||
(320.0, WidthClass::Compact),
|
||||
(599.0, WidthClass::Compact),
|
||||
(599.9, WidthClass::Compact),
|
||||
(600.0, WidthClass::Medium),
|
||||
(720.0, WidthClass::Medium),
|
||||
(839.0, WidthClass::Medium),
|
||||
(839.9, WidthClass::Medium),
|
||||
(840.0, WidthClass::Expanded),
|
||||
(900.0, WidthClass::Expanded),
|
||||
(1180.0, WidthClass::Expanded),
|
||||
(2560.0, WidthClass::Expanded),
|
||||
] {
|
||||
assert_eq!(
|
||||
WidthClass::of(width),
|
||||
expected,
|
||||
"{width} 宽应该判为 {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 版面决议表(tower 会照这张表对文档):一列写死「宽度 → 版面」。
|
||||
#[test]
|
||||
fn layout_table_matches_the_documented_thresholds() {
|
||||
// (窗口宽度, 实例页, 设置页, 下载/日志页)
|
||||
let table = [
|
||||
(
|
||||
320.0,
|
||||
PageLayout::SingleColumn,
|
||||
PageLayout::SingleColumn,
|
||||
PageLayout::SingleColumn,
|
||||
),
|
||||
(
|
||||
599.0,
|
||||
PageLayout::SingleColumn,
|
||||
PageLayout::SingleColumn,
|
||||
PageLayout::SingleColumn,
|
||||
),
|
||||
(
|
||||
700.0,
|
||||
PageLayout::SingleColumn,
|
||||
PageLayout::SingleColumn,
|
||||
PageLayout::SingleColumn,
|
||||
),
|
||||
(
|
||||
720.0,
|
||||
PageLayout::SingleColumn,
|
||||
PageLayout::SupportingPane,
|
||||
PageLayout::SingleColumn,
|
||||
),
|
||||
(
|
||||
800.0,
|
||||
PageLayout::SingleColumn,
|
||||
PageLayout::SupportingPane,
|
||||
PageLayout::SingleColumn,
|
||||
),
|
||||
(
|
||||
839.0,
|
||||
PageLayout::SingleColumn,
|
||||
PageLayout::SupportingPane,
|
||||
PageLayout::SingleColumn,
|
||||
),
|
||||
(
|
||||
840.0,
|
||||
PageLayout::ListDetail,
|
||||
PageLayout::SupportingPane,
|
||||
PageLayout::SingleColumn,
|
||||
),
|
||||
(
|
||||
900.0,
|
||||
PageLayout::ListDetail,
|
||||
PageLayout::SupportingPane,
|
||||
PageLayout::SingleColumn,
|
||||
),
|
||||
(
|
||||
1180.0,
|
||||
PageLayout::ListDetail,
|
||||
PageLayout::SupportingPane,
|
||||
PageLayout::SingleColumn,
|
||||
),
|
||||
(
|
||||
1920.0,
|
||||
PageLayout::ListDetail,
|
||||
PageLayout::SupportingPane,
|
||||
PageLayout::SingleColumn,
|
||||
),
|
||||
];
|
||||
|
||||
for (width, instances, settings, single) in table {
|
||||
assert_eq!(
|
||||
resolve(PaneKit::ListDetail, width),
|
||||
instances,
|
||||
"{width} 宽实例页版面不对"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(PaneKit::Supporting, width),
|
||||
settings,
|
||||
"{width} 宽设置页版面不对"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(PaneKit::Single, width),
|
||||
single,
|
||||
"{width} 宽单列页版面对不"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_thresholds_are_the_sum_of_their_columns() {
|
||||
// 336 + 320 + 24 + 2×24 + 88 = 816
|
||||
assert_eq!(list_detail_min_window(), 816.0);
|
||||
// 240 + 320 + 24 + 2×24 + 88 = 720
|
||||
assert_eq!(supporting_pane_min_window(), 720.0);
|
||||
// 官方 expanded 断点必须**宽于** list-detail 的几何门槛,否则「840 起用 list-detail」是空话
|
||||
assert!(
|
||||
BREAKPOINT_EXPANDED >= list_detail_min_window(),
|
||||
"840 档必须放得下 336 + 24 + 320 的三列和"
|
||||
);
|
||||
// 设置页在 medium 档里就有能用的窗口宽度(720 < 840)
|
||||
assert!(supporting_pane_min_window() < BREAKPOINT_EXPANDED);
|
||||
assert!(WidthClass::of(supporting_pane_min_window()) == WidthClass::Medium);
|
||||
}
|
||||
|
||||
/// 实测样本:**实例页详情那段正文**(`pages/instances/data.rs` 的 `NOTE`)。
|
||||
///
|
||||
/// 口径(`layout` 模块文档 §3):Segoe UI Regular @14px、无 letter-spacing、
|
||||
/// FreeType `getlength` 求整段排版宽。**这是外部实测数据,不是从常量推出来的**——
|
||||
/// 下面的断言拿它反算「一行能放几个字符」,所以把 `CHAR_ADVANCE_EM` 改回估算值会立刻红。
|
||||
const MEASURE_SAMPLE_CHARS: f32 = 361.0;
|
||||
const MEASURE_SAMPLE_WIDTH_PX: f32 = 2185.0;
|
||||
|
||||
/// 样本的实测平均步进(px/字符)。
|
||||
fn measured_advance_px() -> f32 {
|
||||
MEASURE_SAMPLE_WIDTH_PX / MEASURE_SAMPLE_CHARS
|
||||
}
|
||||
|
||||
/// 给定列宽,按**实测步进**能放多少字符。
|
||||
fn chars_per_line(width: f32) -> f32 {
|
||||
width / measured_advance_px()
|
||||
}
|
||||
|
||||
/// **逐行**折行模拟结果(FreeType `getlength` 量宽 + 贪心词折行,脚本见模块文档 §3)。
|
||||
///
|
||||
/// 四个数都是外部实测,口径要分清(视觉 r3 提醒过这里容易写糊):
|
||||
/// - `SAFE_MAX_CHARS` / `SAFE_MAX_CHARS_INK`:cap=424 时**字符最多**的那一行 —— 67 字符,ink **402px**;
|
||||
/// - `SAFE_WIDEST_INK`:cap=424 时**墨迹最宽**的那一行 —— **411px**(不是同一行!402 那行字符多但字形窄);
|
||||
/// - `BREAK_INK` / `BREAK_CHARS`:第一档会冒出 73 字符行的宽度 428px(那行 ink 428px)。
|
||||
///
|
||||
/// 有了它,Rust 侧不用字体引擎也能把「上限必须夹在这些数之间」写成断言。
|
||||
const SIM_SAFE_MAX_CHARS: f32 = 67.0;
|
||||
const SIM_SAFE_MAX_CHARS_INK: f32 = 402.0;
|
||||
const SIM_SAFE_WIDEST_INK: f32 = 411.0;
|
||||
const SIM_BREAK_INK: f32 = 428.0;
|
||||
const SIM_BREAK_CHARS: f32 = 73.0;
|
||||
|
||||
/// 行宽上限必须真的停在 72 字符:名字、常量、**逐行模拟**三者一致。
|
||||
#[test]
|
||||
fn line_length_is_capped_at_72_characters() {
|
||||
// ① 常量对得上实测步进(14px 下的平均步进)
|
||||
let body_size = TypeRole::BodyMedium.style().size_px;
|
||||
assert_eq!(body_size, 14.0);
|
||||
assert!(
|
||||
(measured_advance_px() - body_size * CHAR_ADVANCE_EM).abs() < 0.02,
|
||||
"CHAR_ADVANCE_EM = {CHAR_ADVANCE_EM} 与实测步进 {:.4}px/字符对不上",
|
||||
measured_advance_px()
|
||||
);
|
||||
assert_eq!(measure_floor_px(), 272.0, "floor(14 × 0.43233 × 45)");
|
||||
|
||||
// ② 上限是**逐行**模拟出来的 424,不是「平均步进 × 72」的算术值 435
|
||||
assert_eq!(measure_cap_px(), 424.0, "逐行安全宽(4px 栅格)");
|
||||
assert_eq!(
|
||||
measure_average_bound_px(),
|
||||
435.0,
|
||||
"floor(14 × 0.43233 × 72)"
|
||||
);
|
||||
assert!(
|
||||
measure_cap_px() < measure_average_bound_px(),
|
||||
"平均口径上界必须比逐行安全宽**更宽**——这说明 cap 不是拿平均算出来的"
|
||||
);
|
||||
|
||||
// ③ 交付宽度下两条实测线都装得下:字符最多那行(402px)与墨迹最宽那行(411px)
|
||||
assert!(
|
||||
SIM_SAFE_MAX_CHARS_INK <= measure_cap_px(),
|
||||
"字符最多那行 {}px 超过上限 {}px",
|
||||
SIM_SAFE_MAX_CHARS_INK,
|
||||
measure_cap_px()
|
||||
);
|
||||
assert!(
|
||||
SIM_SAFE_WIDEST_INK <= measure_cap_px(),
|
||||
"墨迹最宽那行 {}px 超过上限 {}px(余量只有 {}px)",
|
||||
SIM_SAFE_WIDEST_INK,
|
||||
measure_cap_px(),
|
||||
measure_cap_px() - SIM_SAFE_WIDEST_INK
|
||||
);
|
||||
const { assert!(SIM_SAFE_MAX_CHARS <= MAX_MEASURE_CHARS) };
|
||||
|
||||
// ④ **两侧夹逼**:这是本条真正的护栏。
|
||||
// 放大到 SIM_BREAK_INK(428)逐行模拟就会重新出现 73 字符行;
|
||||
// 压到 SAFE 以下则纯属白丢宽度。两个方向都会红。
|
||||
assert!(
|
||||
measure_cap_px() < SIM_BREAK_INK,
|
||||
"上限 ≥{SIM_BREAK_INK}px 时逐行模拟会再次出现 {SIM_BREAK_CHARS} 字符行——请重跑折行模拟"
|
||||
);
|
||||
assert!(
|
||||
measure_cap_px() >= SIM_SAFE_WIDEST_INK,
|
||||
"上限 {}px 比模拟出来的最宽行 {}px 还窄,白丢宽度",
|
||||
measure_cap_px(),
|
||||
SIM_SAFE_WIDEST_INK
|
||||
);
|
||||
|
||||
// ⑤ 平均口径的回归护栏:旧 0.5em 的 504px 在实测步进下能放 83 字符
|
||||
assert!(
|
||||
chars_per_line(504.0) > MAX_MEASURE_CHARS + 10.0,
|
||||
"504px 在实测步进下应能放 >82 字符(旧口径的破绽),现在算出来 {:.1}",
|
||||
chars_per_line(504.0)
|
||||
);
|
||||
|
||||
for width in [600.0, 840.0, 900.0, 1180.0, 1440.0, 2560.0] {
|
||||
let column = content_column_width(width);
|
||||
// 平均口径只是次要护栏(逐行口径见上面的模拟常数)
|
||||
assert!(
|
||||
chars_per_line(column) <= MAX_MEASURE_CHARS,
|
||||
"{width} 宽窗口的平均行宽 {:.1} 字符,超过 {MAX_MEASURE_CHARS}",
|
||||
chars_per_line(column)
|
||||
);
|
||||
assert!(column <= measure_cap_px() + f32::EPSILON);
|
||||
}
|
||||
|
||||
// 1180 —— M68 就是在这条上翻的车(712px),现在必须是 424(= measure_cap_px())
|
||||
let wide = content_column_width(1180.0);
|
||||
assert_eq!(wide, measure_cap_px());
|
||||
assert!(wide < 712.0, "不许回到 M68 的全宽单卡(712px)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_length_tracks_width_until_it_hits_the_cap() {
|
||||
// 窄窗:正文列跟着可用宽度走(480 宽还在上限以内),撞上上限就锁死
|
||||
let narrow = content_column_width(480.0);
|
||||
assert!(narrow < measure_cap_px(), "480 宽时正文列应窄于上限");
|
||||
assert_eq!(narrow, available_width(480.0));
|
||||
|
||||
// 单调不减:窗口变宽,正文列不变窄
|
||||
let mut previous = 0.0;
|
||||
for width in [320.0, 480.0, 600.0, 720.0, 840.0, 1024.0, 1180.0, 1600.0] {
|
||||
let column = content_column_width(width);
|
||||
assert!(column >= previous, "{width} 宽的正文列比更窄时还小");
|
||||
previous = column;
|
||||
}
|
||||
|
||||
// 一旦撞上上限就锁死(宽窗不会继续变宽)
|
||||
assert_eq!(content_column_width(1180.0), measure_cap_px());
|
||||
assert_eq!(content_column_width(1920.0), measure_cap_px());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_detail_geometry_keeps_the_list_column_in_range() {
|
||||
let metrics = frame_metrics(PaneKit::ListDetail, 1180.0);
|
||||
assert_eq!(metrics.layout, PageLayout::ListDetail);
|
||||
|
||||
let list = metrics.secondary.expect("list-detail 必须有列表列");
|
||||
assert!(
|
||||
(320.0..=360.0).contains(&list),
|
||||
"列表列 {list} 不在 320~360 参考区间"
|
||||
);
|
||||
assert_eq!(list, LIST_COLUMN_WIDTH);
|
||||
|
||||
// 详情正文列:≤ 行宽上限,且明显窄于「窗口全宽」
|
||||
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
|
||||
assert!(metrics.column < metrics.available, "详情列必须窄于可用宽度");
|
||||
|
||||
// 列表列 + 间隙 + 详情 pane = 可用宽度(不留缝)
|
||||
assert_eq!(
|
||||
list + PANES_GAP + metrics.main_pane,
|
||||
metrics.available,
|
||||
"两列几何必须铺满可用宽度"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_detail_degrades_to_one_column_below_the_threshold() {
|
||||
for width in [320.0, 599.0, 700.0, 816.0, 839.0] {
|
||||
assert_eq!(
|
||||
frame_metrics(PaneKit::ListDetail, width).layout,
|
||||
PageLayout::SingleColumn,
|
||||
"{width} 宽应该退化成单列"
|
||||
);
|
||||
assert!(frame_metrics(PaneKit::ListDetail, width)
|
||||
.secondary
|
||||
.is_none());
|
||||
}
|
||||
|
||||
// 840 = 官方 expanded 断点,list-detail 从这里成立
|
||||
assert_eq!(
|
||||
frame_metrics(PaneKit::ListDetail, 840.0).layout,
|
||||
PageLayout::ListDetail
|
||||
);
|
||||
// 840 宽下详情正文列仍要够宽(≥ 下限),否则等于假分栏
|
||||
let at_breakpoint = frame_metrics(PaneKit::ListDetail, 840.0);
|
||||
assert!(
|
||||
at_breakpoint.column >= measure_floor_px(),
|
||||
"840 宽下详情列 {:?} 低于行宽下限",
|
||||
at_breakpoint.column
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_pane_keeps_the_drawer_width_and_a_readable_form_column() {
|
||||
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
|
||||
assert_eq!(metrics.layout, PageLayout::SupportingPane);
|
||||
assert_eq!(metrics.secondary, Some(SIDE_NAV_WIDTH));
|
||||
assert_eq!(SIDE_NAV_WIDTH, 240.0, "D33:展开态是 240 的 drawer");
|
||||
assert_eq!(metrics.column, measure_cap_px(), "表单列吃满行宽上限");
|
||||
assert_eq!(measure_cap_px(), 424.0);
|
||||
|
||||
let narrow = frame_metrics(PaneKit::Supporting, 700.0);
|
||||
assert_eq!(narrow.layout, PageLayout::SingleColumn);
|
||||
assert!(narrow.secondary.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_grid_drops_columns_as_the_detail_narrows() {
|
||||
// 160 宽卡:3 列要 3×160 + 2×12 = 504(超出行宽上限),所以上限处就是 2 列;2 列要 332
|
||||
assert_eq!(meta_columns(measure_cap_px()), 2, "2×160 + 12 = 332");
|
||||
assert_eq!(meta_columns(340.0), 2);
|
||||
assert_eq!(meta_columns(330.0), 1);
|
||||
|
||||
// 1180 与 900 都是 2 列(160 宽卡三列塞不进行宽上限),差别落在详情列宽
|
||||
let wide = frame_metrics(PaneKit::ListDetail, 1180.0);
|
||||
let mid = frame_metrics(PaneKit::ListDetail, 900.0);
|
||||
assert_eq!(wide.meta_columns, 2);
|
||||
assert_eq!(mid.meta_columns, 2);
|
||||
assert!(mid.column < wide.column, "900 宽下详情列必须真的变窄");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gutters_follow_the_class_and_are_on_the_grid() {
|
||||
assert_eq!(WidthClass::Compact.gutter(), 16.0);
|
||||
assert_eq!(WidthClass::Medium.gutter(), 24.0);
|
||||
assert_eq!(WidthClass::Expanded.gutter(), 24.0);
|
||||
for class in [
|
||||
WidthClass::Compact,
|
||||
WidthClass::Medium,
|
||||
WidthClass::Expanded,
|
||||
] {
|
||||
assert_eq!(class.gutter() % 4.0, 0.0, "{class:?} 的页边距不在 4px 栅格");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_names_are_stable() {
|
||||
assert_eq!(PageLayout::SingleColumn.name(), "single-column");
|
||||
assert_eq!(PageLayout::SupportingPane.name(), "supporting-pane");
|
||||
assert_eq!(PageLayout::ListDetail.name(), "list-detail");
|
||||
assert!(!PageLayout::SingleColumn.is_two_pane());
|
||||
assert!(PageLayout::ListDetail.is_two_pane());
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! 日志页:**单列**版面的第二个用例(两行页头 84 + 正文列 ≤424)。
|
||||
//!
|
||||
//! 与下载页的区别在页头:日志页的页头是**两行**(标题 + 一行运行状态),
|
||||
//! 高度走 `LayoutMetrics::page_header_tall_height`(84),这是 12b §16.1 那栏在骨架期的落地。
|
||||
//!
|
||||
//! 内容区给「日志源」一张卡(文件行)+ 空态位(当前没有输出)。真正的等宽日志视图属于
|
||||
//! 后续「日志页接事件流」的 mission,本页只负责版式。
|
||||
|
||||
use iced::widget::{column, row};
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::frame::{self, Header};
|
||||
use super::layout::FrameMetrics;
|
||||
use super::{Action, Message};
|
||||
|
||||
/// 演示日志源(文件名 + 大小/时间)。
|
||||
const SOURCES: [(&str, &str); 3] = [
|
||||
("launcher.log", "128 KiB · live"),
|
||||
("launch-2026-11-01T09-14-02.log", "412 KiB · 09:14"),
|
||||
("diagnose-2026-10-31.yaml", "18 KiB · yesterday"),
|
||||
];
|
||||
|
||||
/// 整页。
|
||||
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
|
||||
let header = Header::new("Logs")
|
||||
.subtitle("Launcher and game output · nothing is running right now")
|
||||
.action(frame::text_action(ui, "Open folder", Action::OpenLogs))
|
||||
.action(frame::text_action(
|
||||
ui,
|
||||
"Copy diagnostics",
|
||||
Action::CopyDiagnostics,
|
||||
))
|
||||
.action(frame::text_action(ui, "Clear", Action::ClearLogs));
|
||||
|
||||
let body = frame::single_column(
|
||||
metrics,
|
||||
column![
|
||||
source_card(ui),
|
||||
frame::empty_state(
|
||||
ui,
|
||||
IconKind::Logs,
|
||||
"No output yet",
|
||||
"Logs stream here while the launcher or the game is running. Start an instance to see \
|
||||
the first lines appear.",
|
||||
vec![frame::text_action(ui, "Start an instance", Action::Play)],
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X4))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
);
|
||||
|
||||
frame::page(ui, metrics, header, body)
|
||||
}
|
||||
|
||||
/// 日志源列表(12b 的 `ListItem` 档:行文 `BodyLarge`、副文案 `BodySmall`)。
|
||||
fn source_card<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
let spec = Component::ListItem.spec();
|
||||
let mut rows = column![].spacing(0.0).width(Length::Fill);
|
||||
|
||||
for (index, (name, meta)) in SOURCES.iter().enumerate() {
|
||||
if index > 0 {
|
||||
rows = rows.push(frame::divider(ui));
|
||||
}
|
||||
|
||||
rows = rows.push(
|
||||
row![
|
||||
frame::glyph_box(
|
||||
IconKind::Logs,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
frame::ICON_PX
|
||||
),
|
||||
column![
|
||||
frame::text_role(ui, spec.text, false, name, ui.color(Role::OnSurface)),
|
||||
frame::text_role(
|
||||
ui,
|
||||
spec.support.unwrap_or(TypeRole::BodySmall),
|
||||
false,
|
||||
meta,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(0.0)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding::from([ui.space(Space::X2), ui.space(Space::X2)])),
|
||||
);
|
||||
}
|
||||
|
||||
frame::card(ui, rows)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit};
|
||||
use super::*;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
#[test]
|
||||
fn logs_page_is_single_column_and_capped() {
|
||||
for width in [700.0, 1180.0, 1600.0] {
|
||||
let metrics = frame_metrics(PaneKit::Single, width);
|
||||
assert!(metrics.secondary.is_none());
|
||||
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_is_two_lines_because_of_the_status_subtitle() {
|
||||
let ui = Ui::with_platform(
|
||||
SchemeFlavor::Dark,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
);
|
||||
assert_eq!(ui.metrics().header_height(true), 84.0);
|
||||
assert!(ui.metrics().header_height(true) > ui.metrics().header_height(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_source_row_has_a_name_and_meta() {
|
||||
assert_eq!(SOURCES.len(), 3);
|
||||
for (name, meta) in SOURCES {
|
||||
assert!(name.contains("log") || name.contains("yaml"));
|
||||
assert!(!meta.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! # 页面骨架 + 布局系统(P4 / M84 `PAGEFRAME`)
|
||||
//!
|
||||
//! 本模块**不依赖组件库**(`components/` 由 M74/M75 并行建):它交付的是**布局系统**,
|
||||
//! 四个页面只是它的用例。控件全是同一份令牌下的占位实现,等组件家族合入后由另一条 mission 替换。
|
||||
//!
|
||||
//! ## 为什么要做这一层(历史判词)
|
||||
//!
|
||||
//! M68 的骨架内容区是**一张全宽卡**。视觉复审的结论(用户认可):
|
||||
//!
|
||||
//! > **不能把「全宽单卡」当页面模板** —— 正文单行 **712px**,那张卡里的 `BodySmall` 12px 文本
|
||||
//! > **实测装了 137 字符**(口径见 [`layout`] §3),远超舒适行宽;
|
||||
//! > 官方桌面口径是 **supporting-pane / list-detail**。
|
||||
//!
|
||||
//! 所以这里先立三条规矩,页面照着用:
|
||||
//!
|
||||
//! 1. **宽度档位**:官方响应式断点 **600 / 840**([`layout::WidthClass`]);
|
||||
//! 2. **版面**:单列 / supporting-pane / list-detail,各自的成立条件与退化阈值写在 [`layout`];
|
||||
//! 3. **行宽上限**:正文列硬上限 **424px(逐行实测 67 字符 ≤72)**([`layout::measure_cap_px`]),
|
||||
//! 由 [`layout::content_column_width`] / [`layout::frame_metrics`] **无条件施加**,
|
||||
//! 页面拿不到「更宽」的选项。
|
||||
//!
|
||||
//! ## 版面阈值表(`layout` 模块文档是全文,这里给速查)
|
||||
//!
|
||||
//! | 版面 | 用它的页面 | 成立条件 | 退化 |
|
||||
//! |---|---|---|---|
|
||||
//! | `SingleColumn` | 下载 / 日志(永远);实例 / 设置(窄窗) | 总是 | — |
|
||||
//! | `SupportingPane` | 设置 | `w ≥ 600` 且 `w ≥ 720`(240 + 24 + 320 + 2×24 + 88) | 单列(分组按组堆叠) |
|
||||
//! | `ListDetail` | 实例 | `w ≥ 840`(官方 expanded)且 `w ≥ 816` | 单列(列表 ↔ 详情整页切换) |
|
||||
//!
|
||||
//! ## 模块
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | [`layout`] | **布局系统**:档位 / 版面 / 列宽 / 阈值,纯函数 + 单测(无 iced 控件树) |
|
||||
//! | [`frame`] | 页头 / 内容列 / 两列骨架 / 空态 / 卡片 / 分隔线(占位控件) |
|
||||
//! | `instances` | 实例页(list-detail) |
|
||||
//! | `settings` | 设置页(supporting-pane) |
|
||||
//! | `download` / `logs` | 下载 / 日志页(单列,两行页头的差异) |
|
||||
//! | [`harness`] | 截图 harness(`examples/pages_demo.rs` 的入口,**不是** `cli.rs` 的一部分) |
|
||||
//!
|
||||
//! ## 偏离登记(D30 要求逐条写清;视觉复审 r1 要求把末两条补进来)
|
||||
//!
|
||||
//! | # | 偏离 | 为什么 | 什么时候收口 |
|
||||
//! |---|---|---|---|
|
||||
//! | 1 | 页头按 M3 口径**铺满页面宽度**(贴页边距),不与正文列对齐 | AppBar 本来就该满宽;1180 下页头右缘 **1156** 与详情列右缘 **896** 有 **260px 分歧** | 真 top bar 落地时按 M3 定语(页头右侧是操作区,不跟随列宽) |
|
||||
//! | 2 | 空态标题 `HeadlineSmall 24` 比页标题 `TitleLarge 22` 大 | 12b §15.2 明确「空态是唯一允许 Headline 级的场景」,与 §16.4 的页标题档有 spec 张力 | 有意保留,不修 |
|
||||
//! | 3 | 单列页(下载 / 日志)在 1180 下只用掉 **40.6%** 内容宽(424/1044) | 行宽上限的必然结果;空态居中不显空 | 版本浏览真正接数据后重新决议 `PaneKit`(现在写「一串线性进度项」是按单列定的) |
|
||||
//! | 4 | 空态图标字形 **40px**(12b 15.1 是 `[估]` 32~48) | 令牌 `IconSize` 只有 24 一档,`frame::glyph` 按尺寸重算 stroke | 组件库的 `EmptyState`(M77)落地时 |
|
||||
//! | 5 | 窄窗「Back to instances」没有前导箭头 | 占位文本按钮没有图标位 | 真 top bar / M78 的导航件落地时 |
|
||||
//! | 6 | 实例页「未选中详情」空态**没有出图** | 演示数据固定选中第 1 条;空态代码路径在(`detail_empty`) | D30 状态矩阵补齐时补一张,不必重跑全套 |
|
||||
//!
|
||||
//! ## 截图通道
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p unlucky-app-iced --example pages_demo -- \
|
||||
//! --screen=instances --width=1180 --height=760 \
|
||||
//! --screenshot=out.png --screenshot-theme=light --software-renderer
|
||||
//! ```
|
||||
//!
|
||||
//! `cli.rs` 还没有 `--screen` / `--width`(M75 在改它),所以参数解析放在 [`harness`] 里,
|
||||
//! **本模块一行没碰 `cli.rs` / `app.rs`**。
|
||||
|
||||
pub mod frame;
|
||||
pub mod harness;
|
||||
pub mod layout;
|
||||
|
||||
mod download;
|
||||
mod instances;
|
||||
mod logs;
|
||||
mod settings;
|
||||
|
||||
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
|
||||
|
||||
use iced::widget::{container, row, space};
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use frame::filled;
|
||||
|
||||
/// 页面消息。
|
||||
///
|
||||
/// **故意不用 `crate::app::Message`**(组件库契约 §2 的同一条理由):页面不绑 app 的状态层,
|
||||
/// 接线时由 app 层把 [`Message::Action`] 映射成自己的消息。骨架期的动作只有「点了哪个按钮」,
|
||||
/// 没有业务语义。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
/// 点了页面里的某个动作。
|
||||
Action(Action),
|
||||
}
|
||||
|
||||
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// 新建实例。
|
||||
NewInstance,
|
||||
/// 导入。
|
||||
Import,
|
||||
/// 返回实例列表(窄窗整页切换)。
|
||||
Back,
|
||||
/// 启动。
|
||||
Play,
|
||||
/// 更多。
|
||||
More,
|
||||
/// 复制实例。
|
||||
Duplicate,
|
||||
/// 打开实例目录。
|
||||
OpenFolder,
|
||||
/// 删除实例。
|
||||
Delete,
|
||||
/// 重新探测下载源。
|
||||
RefreshSources,
|
||||
/// 浏览版本列表。
|
||||
BrowseVersions,
|
||||
/// 导入整合包。
|
||||
ImportModpack,
|
||||
/// 检查下载源。
|
||||
CheckSources,
|
||||
/// 打开日志目录。
|
||||
OpenLogs,
|
||||
/// 复制诊断信息。
|
||||
CopyDiagnostics,
|
||||
/// 清空日志。
|
||||
ClearLogs,
|
||||
/// 重置当前设置分组。
|
||||
ResetSection,
|
||||
/// 保存设置。
|
||||
SaveSettings,
|
||||
/// 新增逐实例覆盖。
|
||||
AddOverride,
|
||||
}
|
||||
|
||||
/// 四个页面。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Screen {
|
||||
/// 实例(list-detail)。
|
||||
Instances,
|
||||
/// 下载(单列)。
|
||||
Download,
|
||||
/// 设置(supporting-pane)。
|
||||
Settings,
|
||||
/// 日志(单列,两行页头)。
|
||||
Logs,
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
/// 全部页面(截图矩阵按这个顺序铺)。
|
||||
pub const ALL: [Screen; 4] = [
|
||||
Screen::Instances,
|
||||
Screen::Download,
|
||||
Screen::Settings,
|
||||
Screen::Logs,
|
||||
];
|
||||
|
||||
/// 稳定 slug(命令行参数与文件名用)。
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Screen::Instances => "instances",
|
||||
Screen::Download => "download",
|
||||
Screen::Settings => "settings",
|
||||
Screen::Logs => "logs",
|
||||
}
|
||||
}
|
||||
|
||||
/// 反向解析(`--screen=instances`)。
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|screen| screen.slug() == slug)
|
||||
}
|
||||
|
||||
/// 页面声明的**最宽**版面;实际用哪个由 [`layout::resolve`] 按窗口宽度决定。
|
||||
pub const fn kit(self) -> PaneKit {
|
||||
match self {
|
||||
Screen::Instances => PaneKit::ListDetail,
|
||||
Screen::Settings => PaneKit::Supporting,
|
||||
Screen::Download | Screen::Logs => PaneKit::Single,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单独渲染一页(截图 / 手看用,**不依赖业务状态机**)。
|
||||
///
|
||||
/// 窗口宽度是**含外壳的窗口宽度**(档位判据就是它):返回的树里,左侧 88px 与顶部 48px 是
|
||||
/// **留白带**,代表真实外壳的导航轨与标题栏——`shell::nav` 是私有模块,本 mission 不复制它的实现
|
||||
/// (真正的 `NavigationDrawer` 由 M78 提供,接线时把这两条带换成外壳即可)。
|
||||
pub fn demo<'a>(ui: &'a Ui, screen: Screen, window_width: f32) -> Element<'a, Message> {
|
||||
let metrics = layout::frame_metrics(screen.kit(), window_width);
|
||||
|
||||
let page = match screen {
|
||||
Screen::Instances => instances::view(ui, metrics, Some(instances::SELECTED)),
|
||||
Screen::Download => download::view(ui, metrics),
|
||||
Screen::Settings => settings::view(ui, metrics),
|
||||
Screen::Logs => logs::view(ui, metrics),
|
||||
};
|
||||
|
||||
let shell = row![rail_placeholder(ui), page]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let titled = iced::widget::column![titlebar_placeholder(ui), shell]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
container(titled)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 标题栏留白带(高 = `LayoutMetrics::titlebar_height`)。
|
||||
fn titlebar_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
container(space::horizontal())
|
||||
.width(Length::Fill)
|
||||
.height(ui.metrics().titlebar_height)
|
||||
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 导航轨留白带(宽 = `LayoutMetrics::nav_rail_width`)。
|
||||
fn rail_placeholder<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
container(space::horizontal())
|
||||
.width(Length::Fixed(layout::rail_width()))
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui() -> Ui {
|
||||
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_screens_answer_the_mission_list() {
|
||||
assert_eq!(Screen::ALL.len(), 4);
|
||||
let slugs: Vec<&str> = Screen::ALL.iter().map(|screen| screen.slug()).collect();
|
||||
assert_eq!(slugs, vec!["instances", "download", "settings", "logs"]);
|
||||
for screen in Screen::ALL {
|
||||
assert_eq!(Screen::from_slug(screen.slug()), Some(screen));
|
||||
}
|
||||
assert_eq!(Screen::from_slug("gallery"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screens_declare_the_layout_their_page_needs() {
|
||||
assert_eq!(Screen::Instances.kit(), PaneKit::ListDetail);
|
||||
assert_eq!(Screen::Settings.kit(), PaneKit::Supporting);
|
||||
assert_eq!(Screen::Download.kit(), PaneKit::Single);
|
||||
assert_eq!(Screen::Logs.kit(), PaneKit::Single);
|
||||
}
|
||||
|
||||
/// 四个页面 × 三个对照宽度都能构图(不 panic、不依赖运行时)。
|
||||
#[test]
|
||||
fn every_screen_builds_at_the_three_comparison_widths() {
|
||||
let ui = ui();
|
||||
for width in [1180.0, 900.0, 700.0] {
|
||||
for screen in Screen::ALL {
|
||||
let _element: Element<'_, Message> = demo(&ui, screen, width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 版面对照:同一页在 1180 / 900 / 700 三个宽度上必须**真的换版面**。
|
||||
#[test]
|
||||
fn the_width_comparison_really_changes_the_layout() {
|
||||
let wide = layout::frame_metrics(Screen::Instances.kit(), 1180.0);
|
||||
let mid = layout::frame_metrics(Screen::Instances.kit(), 900.0);
|
||||
let narrow = layout::frame_metrics(Screen::Instances.kit(), 700.0);
|
||||
|
||||
assert_eq!(wide.layout, PageLayout::ListDetail);
|
||||
assert_eq!(mid.layout, PageLayout::ListDetail);
|
||||
assert_eq!(narrow.layout, PageLayout::SingleColumn);
|
||||
|
||||
// 900 与 1180 同为 list-detail,但详情列必须真的变窄(不是硬编码 1180 的宽度)
|
||||
assert!(mid.column < wide.column, "900 的详情列应窄于 1180");
|
||||
assert!(narrow.secondary.is_none(), "700 不许再有列表列");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! 设置页:**supporting-pane** 的用例(左侧分组导航 240 + 右侧表单列 ≤424)。
|
||||
//!
|
||||
//! ## 为什么是 supporting-pane 而不是 list-detail
|
||||
//!
|
||||
//! 分组导航是**辅助**(它不承载数据,只切换右侧表单的内容),右侧表单才是阅读对象;
|
||||
//! 官方把这种「主列 + 窄侧列」叫 supporting pane。list-detail 的前提是左列本身是一份可浏览的数据集
|
||||
//! (实例列表就是),设置页不满足。
|
||||
//!
|
||||
//! ## 侧列与 rail 的关系(D33)
|
||||
//!
|
||||
//! 宽度 240 直接取 `LayoutMetrics::nav_drawer_width`(D33:同一容器 rail 88 ⇄ drawer 240 的展开态),
|
||||
//! 但**不复制 rail 的实现**:这里没有 hover 药丸、没有二级标签、没有 `Message::Select`。
|
||||
//! 将来真正的 `NavigationDrawer` 由 M78 提供,本页换掉 `group_nav()` 即可。
|
||||
//!
|
||||
//! ## 退化(窗口 <720 或 compact 档)
|
||||
//!
|
||||
//! 侧列消失,分组导航退化成「按组堆叠」:同一列里每组一张卡 + 一段 12pt 组名。整页仍居中、正文列仍 ≤424。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding};
|
||||
|
||||
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;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::frame::{self, Header};
|
||||
use super::layout::{FrameMetrics, PageLayout};
|
||||
use super::{Action, Message};
|
||||
|
||||
/// 一个设置分组:名字、图标、以及若干「标签 = 值」行。
|
||||
struct Group {
|
||||
name: &'static str,
|
||||
icon: IconKind,
|
||||
rows: &'static [(&'static str, &'static str)],
|
||||
}
|
||||
|
||||
/// 演示分组(骨架期:值只是文本,真控件等 M76 的输入家族)。
|
||||
const GROUPS: [Group; 4] = [
|
||||
Group {
|
||||
name: "General",
|
||||
icon: IconKind::Settings,
|
||||
rows: &[
|
||||
("Language", "English (System)"),
|
||||
("Theme", "Follow system"),
|
||||
("Startup page", "Instances"),
|
||||
("Close button", "Exit to tray"),
|
||||
],
|
||||
},
|
||||
Group {
|
||||
name: "Appearance",
|
||||
icon: IconKind::Sidebar,
|
||||
rows: &[
|
||||
("Density", "Comfortable"),
|
||||
("Corner style", "Rounded"),
|
||||
("Motion", "Full"),
|
||||
("Text scale", "100%"),
|
||||
],
|
||||
},
|
||||
Group {
|
||||
// 描边字形:令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架(视觉复审 P2-3);
|
||||
// Java 分组讲的是运行时安装位置,用目录图标语义也对得上。
|
||||
name: "Java",
|
||||
icon: IconKind::Folder,
|
||||
rows: &[
|
||||
("Runtime", "Temurin 21.0.4"),
|
||||
("Default memory", "4 GiB"),
|
||||
("JVM arguments", "Automatic"),
|
||||
],
|
||||
},
|
||||
Group {
|
||||
name: "Downloads",
|
||||
icon: IconKind::Download,
|
||||
rows: &[
|
||||
("Preferred source", "Automatic (region)"),
|
||||
("Concurrency", "8 threads"),
|
||||
("Verify checksums", "On"),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/// 演示选中的分组。
|
||||
const SELECTED: usize = 0;
|
||||
|
||||
/// 空态位高度:给图标块 96 + 标题 + 副文案 + 按钮留够空间(`[估]`,按 12b §15 的元素最小高度累加)。
|
||||
const EMPTY_SLOT_HEIGHT: f32 = 320.0;
|
||||
|
||||
/// 整页。
|
||||
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
|
||||
let header = Header::new("Settings")
|
||||
.subtitle("Launcher behaviour, Java runtimes and download sources")
|
||||
.action(frame::text_action(
|
||||
ui,
|
||||
"Reset section",
|
||||
Action::ResetSection,
|
||||
))
|
||||
.action(frame::text_action(ui, "Save", Action::SaveSettings));
|
||||
|
||||
let body = match metrics.layout {
|
||||
PageLayout::SupportingPane => frame::panes(
|
||||
metrics,
|
||||
group_nav(ui),
|
||||
frame::left_aligned(metrics.column, form(ui, Some(SELECTED))),
|
||||
),
|
||||
_ => frame::single_column(metrics, form(ui, None)),
|
||||
};
|
||||
|
||||
frame::page(ui, metrics, header, body)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 侧列
|
||||
|
||||
fn group_nav<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
let items = column(
|
||||
GROUPS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, group)| group_item(ui, group, index == SELECTED))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.padding(Padding::from([ui.space(Space::X2), ui.space(Space::X2)]))
|
||||
.width(Length::Fill);
|
||||
|
||||
container(items)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||||
border: Border {
|
||||
radius: ui.radius_dp(ShapeScale::Large),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn group_item<'a>(ui: &'a Ui, group: &'a Group, selected: bool) -> Element<'a, Message> {
|
||||
let spec = Component::NavigationItem.spec();
|
||||
let height = ui.metrics().row_height;
|
||||
|
||||
let content_color = if selected {
|
||||
ui.color(Role::OnSecondaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
let inner = row![
|
||||
frame::glyph_box(group.icon, content_color, frame::ICON_PX),
|
||||
frame::text_role(ui, spec.text, false, group.name, content_color),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
container(inner)
|
||||
.width(Length::Fill)
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X3)]))
|
||||
.align_y(Alignment::Center)
|
||||
.style(move |_theme| container::Style {
|
||||
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, height),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 表单列
|
||||
|
||||
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
|
||||
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
|
||||
fn form<'a>(ui: &'a Ui, only: Option<usize>) -> Element<'a, Message> {
|
||||
let mut stack = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
|
||||
|
||||
for (index, group) in GROUPS.iter().enumerate() {
|
||||
if only.is_some_and(|selected| selected != index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
stack = stack.push(
|
||||
column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
group.name,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
group_card(ui, group),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill),
|
||||
);
|
||||
}
|
||||
|
||||
stack = stack.push(overrides_empty(ui));
|
||||
stack.into()
|
||||
}
|
||||
|
||||
/// 一组设置 = 一张卡,行间 1px 分隔线(M3 设置列表的常规做法)。
|
||||
fn group_card<'a>(ui: &'a Ui, group: &'a Group) -> Element<'a, Message> {
|
||||
let mut rows = column![].spacing(0.0).width(Length::Fill);
|
||||
|
||||
for (index, (label, value)) in group.rows.iter().enumerate() {
|
||||
if index > 0 {
|
||||
rows = rows.push(frame::divider(ui));
|
||||
}
|
||||
rows = rows.push(setting_row(ui, label, value));
|
||||
}
|
||||
|
||||
frame::card(ui, rows)
|
||||
}
|
||||
|
||||
fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Message> {
|
||||
let height = ui.metrics().control_height + Space::X2.dp();
|
||||
|
||||
row![
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
label,
|
||||
ui.color(Role::OnSurface),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
value,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.height(height)
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 空态位:没有逐实例覆盖时的样子(12b §15 的口径)。
|
||||
fn overrides_empty<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
container(frame::empty_state(
|
||||
ui,
|
||||
IconKind::Instances,
|
||||
"No per-instance overrides",
|
||||
"Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest \
|
||||
of the launcher keeps the defaults above.",
|
||||
vec![frame::text_action(ui, "Add override", Action::AddOverride)],
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(EMPTY_SLOT_HEIGHT))
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn side_nav_reuses_the_drawer_width() {
|
||||
// D33:分组导航走 drawer 240(同一容器 rail 88 ⇄ drawer 240),不是 rail 的 88
|
||||
assert_eq!(SIDE_NAV_WIDTH, 240.0);
|
||||
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
|
||||
assert_eq!(metrics.secondary, Some(240.0));
|
||||
assert_ne!(metrics.secondary, Some(layout::rail_width()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_column_never_exceeds_the_measure_cap() {
|
||||
for width in [720.0, 840.0, 1180.0, 1920.0] {
|
||||
let metrics = frame_metrics(PaneKit::Supporting, width);
|
||||
assert!(
|
||||
metrics.column <= measure_cap_px() + f32::EPSILON,
|
||||
"{width} 宽下表单列 {:?} 超过行宽上限",
|
||||
metrics.column
|
||||
);
|
||||
assert!(metrics.column >= layout::measure_floor_px());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_group_has_a_name_icon_and_rows() {
|
||||
assert!(GROUPS.len() >= 3);
|
||||
assert!(SELECTED < GROUPS.len());
|
||||
for group in &GROUPS {
|
||||
assert!(!group.name.is_empty());
|
||||
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name);
|
||||
assert!(!group.rows.is_empty(), "{} 没有设置行", group.name);
|
||||
for (label, value) in group.rows {
|
||||
assert!(!label.is_empty() && !value.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_window_stacks_the_groups_instead_of_the_side_nav() {
|
||||
let narrow = frame_metrics(PaneKit::Supporting, 700.0);
|
||||
assert_eq!(narrow.layout, PageLayout::SingleColumn);
|
||||
assert!(narrow.secondary.is_none(), "窄窗不许留侧列");
|
||||
}
|
||||
}
|
||||
@@ -202,20 +202,26 @@ fn size_literal(argument: &str) -> Option<String> {
|
||||
Some(number)
|
||||
}
|
||||
|
||||
/// 尺寸字面量守门:`src/shell` + `src/pages` 都扫。
|
||||
///
|
||||
/// P2-3(M84 复审):原先只扫 `shell`,新增的 `pages/` **没被覆盖**(页面里写 `.width(24)` 没人发现)。
|
||||
/// `pages/layout.rs` 允许有布局常量(它是布局系统的定义点),控件调用点上不许再出现字面量。
|
||||
#[test]
|
||||
fn shell_takes_every_size_from_tokens() {
|
||||
fn layout_sources_take_every_size_from_tokens() {
|
||||
let mut files = Vec::new();
|
||||
rust_files(&manifest_dir().join("src").join("shell"), &mut files);
|
||||
for dir in ["shell", "pages"] {
|
||||
rust_files(&manifest_dir().join("src").join(dir), &mut files);
|
||||
}
|
||||
assert!(
|
||||
files.len() >= 2,
|
||||
"shell 模块至少有两个文件(mod.rs + nav.rs),实际扫到 {}",
|
||||
files.len() >= 10,
|
||||
"shell + pages 至少 10 个文件(shell 2 + pages 8+),实际扫到 {}:扫描逻辑坏了,这条门禁等于没跑",
|
||||
files.len()
|
||||
);
|
||||
|
||||
let mut offenders = Vec::new();
|
||||
|
||||
for path in files {
|
||||
let text = std::fs::read_to_string(&path).expect("读 shell 源文件");
|
||||
let text = std::fs::read_to_string(&path).expect("读布局源文件");
|
||||
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
// 文档注释是散文不是代码:里面写 `.width(24)` 是在解释坑,不该判违规
|
||||
@@ -247,6 +253,48 @@ fn shell_takes_every_size_from_tokens() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 图标语汇守门(视觉复审 P2-3):`pages/` 里只用**描边**字形。
|
||||
///
|
||||
/// 令牌层把 `Play` / `Stop` / `More` 定义成填充字形,其余是描边;页面骨架里混用会让同屏线宽
|
||||
/// 语汇打架。将来要支持「主操作允许实心图标」,应由 `components/` 统一定义并只留一处。
|
||||
#[test]
|
||||
fn pages_use_only_stroked_icons() {
|
||||
const FILLED: [&str; 3] = ["IconKind::Play", "IconKind::Stop", "IconKind::More"];
|
||||
|
||||
let mut files = Vec::new();
|
||||
rust_files(&manifest_dir().join("src").join("pages"), &mut files);
|
||||
assert!(
|
||||
files.len() >= 8,
|
||||
"pages 至少 8 个文件,实扫 {}",
|
||||
files.len()
|
||||
);
|
||||
|
||||
let mut offenders = Vec::new();
|
||||
for path in files {
|
||||
let text = std::fs::read_to_string(&path).expect("读 pages 源文件");
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
if line.trim_start().starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
for kind in FILLED {
|
||||
if line.contains(kind) {
|
||||
offenders.push(format!("{}:{} {line:?}", path.display(), index + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"pages 里出现了填充字形图标(令牌层 Play/Stop/More),与同屏描边语汇冲突:
|
||||
{}",
|
||||
offenders.join(
|
||||
"
|
||||
"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// 依赖行里出现 `slint` 就算违规(注释行放过:说明并存期要提到 slint)。
|
||||
fn manifest_has_slint_dependency(manifest: &str) -> Vec<String> {
|
||||
manifest
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
//! - [`linux_caps`]:发行版(仅显示)+ 内核 release(喂 `os.version`)+ 会话/GL 能力探测;
|
||||
//! - [`vk_probe`]:Vulkan 能力的安全层探测(loader 文件 / ICD 注册 / 环境变量,无 `unsafe`);
|
||||
//! - [`theme_probe`]:系统主题(浅色/深色)探测,供主题三态的 `auto` 初始化对齐与兜底跟随。
|
||||
//! - [`wallpaper`]:桌面壁纸**路径探测**(D38 配色三级优先里第②级的输入)。本模块只做路径
|
||||
//! 探测——不解码图片、不取色、不生成调色板,莫奈取色与三级降级在 `crates/design` 的
|
||||
//! 后续 mission。
|
||||
//!
|
||||
//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见
|
||||
//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。
|
||||
@@ -39,4 +42,5 @@ pub mod receipt;
|
||||
pub mod redirect;
|
||||
pub mod theme_probe;
|
||||
pub mod vk_probe;
|
||||
pub mod wallpaper;
|
||||
pub mod windows_version;
|
||||
@@ -0,0 +1,358 @@
|
||||
//! 桌面壁纸**路径探测**(D38 配色三级优先里的第②级输入)。
|
||||
//!
|
||||
//! 用户原话(D38,2026-11-01):「如果用户设置了启动器背景图,则使用莫奈取色从背景图取,
|
||||
//! 没有背景图时用户也可以开启莫奈取色,此时从桌面壁纸取色。」本模块只承担中间那一级的
|
||||
//! **输入**:按可信度给出「这台机器的桌面壁纸可能是哪个文件」的有序候选列表。
|
||||
//!
|
||||
//! # 边界(只做路径探测)
|
||||
//!
|
||||
//! - **不解码图片、不取色、不生成调色板**。莫奈取色本身、D38 三级降级、
|
||||
//! 「取色失败回落到色卡当前选择 + 设置页可读提示」都在 `crates/design` 的后续 mission。
|
||||
//! - 全部找不到时返回**空列表**([`WallpaperProbe::is_empty`])而不是错误:没有桌面壁纸是
|
||||
//! 正常状态(服务器、无桌面环境、壁纸是纯色),调用方按 D38 直接落到第③级色卡。
|
||||
//! - **零 `unsafe`、零 FFI**(core 是 `#![forbid(unsafe_code)]`):不用
|
||||
//! `SystemParametersInfo(SPI_GETDESKWALLPAPER)`、不碰 win32 注册表 API,全部走文件读取与
|
||||
//! `reg.exe` / `gsettings` / `defaults` 子进程——与 [`super::theme_probe`]、
|
||||
//! [`super::windows_version`] 同一套路子。
|
||||
//!
|
||||
//! # 分层与可测性
|
||||
//!
|
||||
//! - 采集逻辑按平台分文件(`windows` / `linux` / `macos` 子模块),**无条件编译**、不按
|
||||
//! `cfg` 裁剪:CI 是 Linux,Windows / macOS 的夹具用例同样要能在那里跑;
|
||||
//! - 平台分流只发生在 [`detect_with`] 一处;
|
||||
//! - 外部 IO 全部经 [`ProbeIo`](文件内容 / 文件头 / 目录列表 / 子进程)与
|
||||
//! [`EnvSource`](环境变量表)注入,[`detect`] 只是把它们换成真实实现。
|
||||
//!
|
||||
//! # 什么算「候选壁纸」
|
||||
//!
|
||||
//! 只收「归一化后是绝对路径 + 文件头认得出是图片」的条目(魔数集合见 `parse` 子模块的
|
||||
//! `looks_like_image`)。这条校验既挡掉过期配置里指向已删除文件的路径,也挡掉 macOS
|
||||
//! 二进制 plist 字符串扫描出的碎片串。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::platform::paths::{EnvSource, Platform, ProcessEnv};
|
||||
|
||||
use self::io::{HostIo, HostRoots, ProbeIo};
|
||||
use self::parse::{looks_like_image, normalize_path};
|
||||
|
||||
mod io;
|
||||
mod linux;
|
||||
mod macos;
|
||||
mod parse;
|
||||
mod windows;
|
||||
|
||||
/// 读文件头做魔数嗅探时的字节数(够 `ftyp` 分支与文本 SVG 头)。
|
||||
const IMAGE_HEAD_LEN: usize = 512;
|
||||
|
||||
/// 一个候选壁纸是**怎么被找到的**:平台 + 桌面环境标识。
|
||||
///
|
||||
/// 标识只表示「这条路径从哪个通道读出来」,不承诺该通道一定存在(读不到就走下一个)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SourceKind {
|
||||
/// Windows:`%APPDATA%\Microsoft\Windows\Themes\TranscodedWallpaper`(系统转码后的当前壁纸)。
|
||||
WindowsTranscoded,
|
||||
/// Windows:`HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme`
|
||||
/// 指向的 `.theme`(Win10+ 落在 `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`)。
|
||||
///
|
||||
/// 这是**当前生效**的那份主题,比 `%APPDATA%` 下的主题包可靠得多。
|
||||
WindowsCurrentTheme,
|
||||
/// Windows:`%APPDATA%\Microsoft\Windows\Themes\*.theme` 里的 `Wallpaper=` 行。
|
||||
///
|
||||
/// 用户另存的主题包(不一定被启用),只作最后一档兜底。
|
||||
WindowsThemeFile,
|
||||
/// Windows:注册表 `HKCU\Control Panel\Desktop` 的 `WallPaper` 值(`reg.exe` 子进程)。
|
||||
WindowsRegistry,
|
||||
/// Linux · GNOME:`gsettings get org.gnome.desktop.background picture-uri`(`-dark`)。
|
||||
Gnome,
|
||||
/// Linux · KDE Plasma:`plasma-org.kde.plasma.desktop-appletsrc` 的 `Image=` 行。
|
||||
Kde,
|
||||
/// Linux · swaybg / hyprpaper 配置里的 wallpaper 行。
|
||||
WlrootsCompositor,
|
||||
/// Linux · XFCE:`xfce4-desktop.xml` 的 `last-image` / `image-path` 属性。
|
||||
Xfce,
|
||||
/// macOS:`com.apple.wallpaper/Store/Index.plist` 里的 file URL / 绝对路径串。
|
||||
MacOsStore,
|
||||
/// macOS:`defaults read com.apple.desktop Background`(老式每屏壁纸设置)。
|
||||
MacOsDesktopDefaults,
|
||||
}
|
||||
|
||||
impl SourceKind {
|
||||
/// 稳定标识(日志 / 设置页提示用)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::WindowsTranscoded => "windows-transcoded",
|
||||
Self::WindowsCurrentTheme => "windows-current-theme",
|
||||
Self::WindowsThemeFile => "windows-theme-file",
|
||||
Self::WindowsRegistry => "windows-registry",
|
||||
Self::Gnome => "linux-gnome",
|
||||
Self::Kde => "linux-kde",
|
||||
Self::WlrootsCompositor => "linux-wlroots",
|
||||
Self::Xfce => "linux-xfce",
|
||||
Self::MacOsStore => "macos-store",
|
||||
Self::MacOsDesktopDefaults => "macos-desktop-defaults",
|
||||
}
|
||||
}
|
||||
|
||||
/// 这条通道属于哪个平台族。
|
||||
#[must_use]
|
||||
pub const fn platform(self) -> Platform {
|
||||
match self {
|
||||
Self::WindowsTranscoded
|
||||
| Self::WindowsCurrentTheme
|
||||
| Self::WindowsThemeFile
|
||||
| Self::WindowsRegistry => Platform::Windows,
|
||||
Self::MacOsStore | Self::MacOsDesktopDefaults => Platform::MacOs,
|
||||
Self::Gnome | Self::Kde | Self::WlrootsCompositor | Self::Xfce => Platform::Linux,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个候选壁纸来源。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WallpaperSource {
|
||||
/// 通道标识(平台 + 桌面环境)。
|
||||
pub kind: SourceKind,
|
||||
/// 归一化后的绝对路径。
|
||||
pub path: PathBuf,
|
||||
/// 原始证据:配置文件 / 注册表键 / 命令行,用于日志与设置页的「从哪读到的」。
|
||||
pub evidence: String,
|
||||
}
|
||||
|
||||
/// 探测结果:有序候选列表 + 各步失败原因。
|
||||
///
|
||||
/// 列表**不是** `Result`:找不到壁纸不是错误,见模块文档的边界一节。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct WallpaperProbe {
|
||||
sources: Vec<WallpaperSource>,
|
||||
notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl WallpaperProbe {
|
||||
/// 候选列表,按可信度从高到低(第一个就是首选)。
|
||||
#[must_use]
|
||||
pub fn sources(&self) -> &[WallpaperSource] {
|
||||
&self.sources
|
||||
}
|
||||
|
||||
/// 首选候选(等价于 `sources().first()`)。
|
||||
#[must_use]
|
||||
pub fn best(&self) -> Option<&WallpaperSource> {
|
||||
self.sources.first()
|
||||
}
|
||||
|
||||
/// 候选迭代器(按可信度顺序)。
|
||||
pub fn iter(&self) -> impl Iterator<Item = &WallpaperSource> {
|
||||
self.sources.iter()
|
||||
}
|
||||
|
||||
/// 候选个数。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.sources.len()
|
||||
}
|
||||
|
||||
/// 一个候选都没有(调用方应落到 D38 的第③级色卡)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sources.is_empty()
|
||||
}
|
||||
|
||||
/// 各步的失败原因(`通道: 原因`),可直接展示在设置页的提示里。
|
||||
#[must_use]
|
||||
pub fn notes(&self) -> &[String] {
|
||||
&self.notes
|
||||
}
|
||||
|
||||
/// 取走候选列表。
|
||||
#[must_use]
|
||||
pub fn into_sources(self) -> Vec<WallpaperSource> {
|
||||
self.sources
|
||||
}
|
||||
}
|
||||
|
||||
/// 真实探测宿主平台([`HostIo`] + 进程环境 + 编译目标平台)。
|
||||
#[must_use]
|
||||
pub fn detect() -> WallpaperProbe {
|
||||
detect_with(&HostIo, &ProcessEnv, Platform::current())
|
||||
}
|
||||
|
||||
/// 注入式探测:IO、环境变量表、平台族全部由调用方给。
|
||||
///
|
||||
/// 三平台采集逻辑都不带 `cfg`,所以任意宿主都能用固定夹具跑某一平台的用例。
|
||||
#[must_use]
|
||||
pub fn detect_with(io: &dyn ProbeIo, env: &dyn EnvSource, platform: Platform) -> WallpaperProbe {
|
||||
let Some(roots) = HostRoots::from_env(env) else {
|
||||
return WallpaperProbe {
|
||||
sources: Vec::new(),
|
||||
notes: vec!["host: HOME / USERPROFILE 都读不到,无法定位用户目录".to_owned()],
|
||||
};
|
||||
};
|
||||
let mut collect = Collect::new(io, roots, platform);
|
||||
match platform {
|
||||
Platform::Windows => windows::collect(&mut collect),
|
||||
Platform::MacOs => macos::collect(&mut collect),
|
||||
// 其它 Unix 按 Linux 的桌面习惯兜底(与 `paths` 模块的处理一致)。
|
||||
Platform::Linux | Platform::Other => linux::collect(&mut collect),
|
||||
}
|
||||
collect.finish()
|
||||
}
|
||||
|
||||
/// 采集过程的累加器:负责归一化、去重、图片校验与失败原因记录。
|
||||
pub(crate) struct Collect<'io> {
|
||||
io: &'io dyn ProbeIo,
|
||||
roots: HostRoots,
|
||||
platform: Platform,
|
||||
sources: Vec<WallpaperSource>,
|
||||
notes: Vec<String>,
|
||||
seen: Vec<String>,
|
||||
}
|
||||
|
||||
impl<'io> Collect<'io> {
|
||||
/// 构造空累加器。
|
||||
pub(crate) fn new(io: &'io dyn ProbeIo, roots: HostRoots, platform: Platform) -> Self {
|
||||
Self {
|
||||
io,
|
||||
roots,
|
||||
platform,
|
||||
sources: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
seen: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入的 IO(文件 / 目录 / 子进程)。
|
||||
pub(crate) fn io(&self) -> &'io dyn ProbeIo {
|
||||
self.io
|
||||
}
|
||||
|
||||
/// 宿主根目录(由环境变量表推出)。
|
||||
pub(crate) fn roots(&self) -> &HostRoots {
|
||||
&self.roots
|
||||
}
|
||||
|
||||
/// 记一条失败原因,探测继续往下走。
|
||||
pub(crate) fn note(&mut self, step: &str, detail: impl std::fmt::Display) {
|
||||
self.notes.push(format!("{step}: {detail}"));
|
||||
}
|
||||
|
||||
/// 收一个候选:归一化 → 去重 → 图片校验,任何一步不过都只记原因、不中断。
|
||||
///
|
||||
/// 返回是否真的收下(调用方据此决定要不要继续试别名)。
|
||||
pub(crate) fn add(&mut self, kind: SourceKind, evidence: &str, raw: &str) -> bool {
|
||||
match normalize_path(raw, &self.roots) {
|
||||
Some(path) => self.add_path(kind, evidence, path),
|
||||
None => {
|
||||
self.note(evidence, format!("不是可用的绝对路径({raw})"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 收一个已经算好的路径(调用方自己拼的,不再归一化)。
|
||||
pub(crate) fn add_path(&mut self, kind: SourceKind, evidence: &str, path: PathBuf) -> bool {
|
||||
let key = dedupe_key(self.platform, &path);
|
||||
if self.seen.contains(&key) {
|
||||
return false;
|
||||
}
|
||||
let head = self.io.read_head(&path, IMAGE_HEAD_LEN);
|
||||
if !head.as_deref().is_some_and(looks_like_image) {
|
||||
self.note(
|
||||
evidence,
|
||||
format!("读不到或认不出图片格式({})", path.display()),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
self.seen.push(key);
|
||||
self.sources.push(WallpaperSource {
|
||||
kind,
|
||||
path,
|
||||
evidence: evidence.to_owned(),
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// 收尾。
|
||||
pub(crate) fn finish(self) -> WallpaperProbe {
|
||||
WallpaperProbe {
|
||||
sources: self.sources,
|
||||
notes: self.notes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 去重键:Windows 路径大小写不敏感(同一文件可能被写出两种大小写),其它平台按原样。
|
||||
fn dedupe_key(platform: Platform, path: &std::path::Path) -> String {
|
||||
let text = path.to_string_lossy().into_owned();
|
||||
if platform == Platform::Windows {
|
||||
text.to_ascii_lowercase()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::io::{HostIo, HostRoots};
|
||||
use super::*;
|
||||
use crate::platform::paths::MapEnv;
|
||||
|
||||
#[test]
|
||||
fn empty_probe_is_empty_not_error() {
|
||||
let probe = WallpaperProbe::default();
|
||||
assert!(probe.is_empty());
|
||||
assert_eq!(probe.len(), 0);
|
||||
assert!(probe.best().is_none());
|
||||
assert_eq!(probe.iter().count(), 0);
|
||||
assert!(probe.notes().is_empty());
|
||||
assert!(probe.into_sources().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_home_yields_note_and_no_candidates() {
|
||||
let env = MapEnv::new();
|
||||
for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
|
||||
let probe = detect_with(&HostIo, &env, platform);
|
||||
assert!(probe.is_empty(), "{platform:?} 没有 HOME 时不该有候选");
|
||||
assert_eq!(probe.notes().len(), 1);
|
||||
assert!(probe.notes()[0].contains("HOME"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_kind_exposes_platform_and_stable_name() {
|
||||
assert_eq!(SourceKind::Gnome.platform(), Platform::Linux);
|
||||
assert_eq!(SourceKind::WindowsRegistry.platform(), Platform::Windows);
|
||||
assert_eq!(SourceKind::MacOsStore.platform(), Platform::MacOs);
|
||||
assert_eq!(SourceKind::WindowsTranscoded.as_str(), "windows-transcoded");
|
||||
assert!(SourceKind::Kde.as_str().starts_with("linux-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_dedupes_and_keeps_priority_order() {
|
||||
let env = MapEnv::new().with("HOME", "/home/u");
|
||||
let roots = HostRoots::from_env(&env).expect("HOME 已给");
|
||||
let io = io::fake::FakeIo::new()
|
||||
.with_image("/home/u/a.png")
|
||||
.with_image("/home/u/b.jpg");
|
||||
let mut collect = Collect::new(&io, roots, Platform::Linux);
|
||||
collect.add(SourceKind::Gnome, "gsettings", "/home/u/a.png");
|
||||
collect.add(SourceKind::Kde, "kde", "file:///home/u/b.jpg");
|
||||
// 同一条路径再来一次(换了个通道)不该重复计入。
|
||||
collect.add(SourceKind::Xfce, "xfce", "/home/u/a.png");
|
||||
collect.add(SourceKind::Xfce, "xfce", "/home/u/missing.png");
|
||||
let probe = collect.finish();
|
||||
assert_eq!(probe.len(), 2);
|
||||
assert_eq!(probe.best().expect("有候选").kind, SourceKind::Gnome);
|
||||
assert_eq!(probe.sources()[1].path, PathBuf::from("/home/u/b.jpg"));
|
||||
assert!(
|
||||
probe
|
||||
.notes()
|
||||
.iter()
|
||||
.any(|note| note.contains("missing.png")),
|
||||
"落选的候选要留原因:{:?}",
|
||||
probe.notes()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! 壁纸探测要用的外部 IO:文件读取、文件头、目录列表、子进程,以及宿主根目录。
|
||||
//!
|
||||
//! 生产实现是 [`HostIo`],测试实现是 `fake::FakeIo`(固定夹具,不碰真机壁纸)。
|
||||
//! 这一层的存在只为把「外部世界」变成注入参数,不含任何平台判断。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::platform::paths::EnvSource;
|
||||
|
||||
/// 探测需要的外部世界:四个方法覆盖三平台的全部读法。
|
||||
pub trait ProbeIo {
|
||||
/// 读整个文件并按 UTF-8 lossy 转文本。
|
||||
///
|
||||
/// 二进制 plist 也走这里:路径串本身是可读 ASCII,坏字节会被替换成 U+FFFD 而不影响
|
||||
/// 路径串的完整性(macOS 的 `Index.plist` 就是 bplist00)。
|
||||
fn read_text(&self, path: &Path) -> Option<String>;
|
||||
|
||||
/// 读文件头最多 `limit` 字节(图片魔数嗅探用;不存在或读不了返回 `None`)。
|
||||
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>>;
|
||||
|
||||
/// 列出目录里的**文件**(非递归、不排序;目录不存在返回空列表)。排序由调用方做。
|
||||
fn list_dir(&self, dir: &Path) -> Vec<PathBuf>;
|
||||
|
||||
/// 跑一个外部命令取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String>;
|
||||
}
|
||||
|
||||
/// 真实宿主 IO。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct HostIo;
|
||||
|
||||
impl ProbeIo for HostIo {
|
||||
fn read_text(&self, path: &Path) -> Option<String> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let mut buffer = vec![0_u8; limit];
|
||||
let mut handle = file.take(limit as u64);
|
||||
let read = handle.read(&mut buffer).ok()?;
|
||||
buffer.truncate(read);
|
||||
Some(buffer)
|
||||
}
|
||||
|
||||
fn list_dir(&self, dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_type()
|
||||
.map(|kind| kind.is_file())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|entry| entry.path())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
|
||||
let output = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
// `Command::output` 对非零退出同样返回 `Ok`,所以退出码要单独判:三条命令
|
||||
// (`reg.exe` / `gsettings` / `defaults`)出错时都走 stderr,只看 stdout 会把
|
||||
// 「查不到」和「命令失败」混成一种,调用方的原因文案就名不副实了。
|
||||
if !output.status.success() || output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测要用的宿主根目录(全部由环境变量表推出,因此测试可以整体替换)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HostRoots {
|
||||
/// 用户主目录(`HOME`,退 `USERPROFILE`)。
|
||||
pub home: PathBuf,
|
||||
/// Windows `%APPDATA%`(退 `~/AppData/Roaming`);其它平台用不到,但仍填上以免分支取空。
|
||||
pub app_data: PathBuf,
|
||||
/// Windows `%LOCALAPPDATA%`(退 `~/AppData/Local`):Win10+ 的「当前主题」
|
||||
/// `Custom.theme` 落在这一侧。
|
||||
pub local_app_data: PathBuf,
|
||||
/// `$XDG_CONFIG_HOME`(仅接受绝对路径),退 `~/.config`。
|
||||
pub config_home: PathBuf,
|
||||
/// Windows `%SystemRoot%`(`.theme` 里 `Wallpaper=%SystemRoot%\...` 展开用),无则不展开。
|
||||
pub system_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl HostRoots {
|
||||
/// 从环境变量表推出根目录;连主目录都没有时返回 `None`(调用方记一条原因)。
|
||||
///
|
||||
/// 顺序固定(`HOME` 优先于 `USERPROFILE`),**不按编译目标分支**:否则在 Windows 上
|
||||
/// 跑 Linux 夹具时会被 `USERPROFILE` 抢走主目录,夹具就不再确定。
|
||||
#[must_use]
|
||||
pub fn from_env(env: &dyn EnvSource) -> Option<Self> {
|
||||
let home = env
|
||||
.var("HOME")
|
||||
.or_else(|| env.var("USERPROFILE"))
|
||||
.map(PathBuf::from)?;
|
||||
let app_data = env
|
||||
.var("APPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home.join("AppData/Roaming"));
|
||||
let local_app_data = env
|
||||
.var("LOCALAPPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home.join("AppData/Local"));
|
||||
let config_home = env
|
||||
.var("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|dir| dir.is_absolute())
|
||||
.unwrap_or_else(|| home.join(".config"));
|
||||
let system_root = env
|
||||
.var("SYSTEMROOT")
|
||||
.or_else(|| env.var("WINDIR"))
|
||||
.map(PathBuf::from);
|
||||
Some(Self {
|
||||
home,
|
||||
app_data,
|
||||
local_app_data,
|
||||
config_home,
|
||||
system_root,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod fake {
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::ProbeIo;
|
||||
|
||||
/// PNG 魔数(夹具用它冒充「像图片」的文件)。
|
||||
pub(crate) const PNG_HEAD: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
|
||||
|
||||
/// 固定夹具 IO:文件内容、图片、目录列表、命令输出全部预先给定。
|
||||
///
|
||||
/// 未登记的路径一律「不存在 / 读不出 / 目录为空」,命令也未登记则报失败。
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct FakeIo {
|
||||
texts: BTreeMap<PathBuf, String>,
|
||||
images: Vec<PathBuf>,
|
||||
dirs: BTreeMap<PathBuf, Vec<PathBuf>>,
|
||||
commands: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl FakeIo {
|
||||
/// 空世界(一切探测都会失败)。
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 登记一个文本文件(配置文件、`.theme`、plist、xml……)。
|
||||
pub(crate) fn with_file(mut self, path: impl AsRef<Path>, text: &str) -> Self {
|
||||
self.texts
|
||||
.insert(path.as_ref().to_path_buf(), text.to_owned());
|
||||
self
|
||||
}
|
||||
|
||||
/// 登记一个「图片」文件(`read_head` 返回 PNG 魔数)。
|
||||
pub(crate) fn with_image(mut self, path: impl AsRef<Path>) -> Self {
|
||||
self.images.push(path.as_ref().to_path_buf());
|
||||
self
|
||||
}
|
||||
|
||||
/// 登记目录内容(按文件名给,路径拼在 `dir` 上)。
|
||||
pub(crate) fn with_dir(mut self, dir: impl AsRef<Path>, names: &[&str]) -> Self {
|
||||
let dir = dir.as_ref();
|
||||
let entries = names.iter().map(|name| dir.join(name)).collect();
|
||||
self.dirs.insert(dir.to_path_buf(), entries);
|
||||
self
|
||||
}
|
||||
|
||||
/// 登记一条命令的 stdout;`key` 是「程序名 + 空格分隔的参数」整串。
|
||||
pub(crate) fn with_command(mut self, key: &str, stdout: &str) -> Self {
|
||||
self.commands.insert(key.to_owned(), stdout.to_owned());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn command_key(program: &str, args: &[&str]) -> String {
|
||||
let mut key = program.to_owned();
|
||||
for arg in args {
|
||||
key.push(' ');
|
||||
key.push_str(arg);
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
impl ProbeIo for FakeIo {
|
||||
fn read_text(&self, path: &Path) -> Option<String> {
|
||||
self.texts.get(path).cloned()
|
||||
}
|
||||
|
||||
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>> {
|
||||
if self.images.iter().any(|image| image.as_path() == path) {
|
||||
return Some(PNG_HEAD[..limit.min(PNG_HEAD.len())].to_vec());
|
||||
}
|
||||
let text = self.texts.get(path)?;
|
||||
let bytes = text.as_bytes();
|
||||
Some(bytes[..limit.min(bytes.len())].to_vec())
|
||||
}
|
||||
|
||||
fn list_dir(&self, dir: &Path) -> Vec<PathBuf> {
|
||||
self.dirs.get(dir).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
|
||||
let key = command_key(program, args);
|
||||
let stdout = self.commands.get(&key)?;
|
||||
if stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(stdout.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::platform::paths::MapEnv;
|
||||
|
||||
#[test]
|
||||
fn roots_come_from_env_in_fixed_order() {
|
||||
let env = MapEnv::new()
|
||||
.with("HOME", "/home/u")
|
||||
.with("USERPROFILE", "C:/Users/ignored")
|
||||
.with("SYSTEMROOT", "C:/Windows");
|
||||
let roots = HostRoots::from_env(&env).expect("HOME 已给");
|
||||
assert_eq!(roots.home, PathBuf::from("/home/u"));
|
||||
assert_eq!(roots.app_data, PathBuf::from("/home/u/AppData/Roaming"));
|
||||
assert_eq!(roots.local_app_data, PathBuf::from("/home/u/AppData/Local"));
|
||||
assert_eq!(roots.config_home, PathBuf::from("/home/u/.config"));
|
||||
assert_eq!(roots.system_root, Some(PathBuf::from("C:/Windows")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roots_prefer_explicit_appdata_and_absolute_xdg() {
|
||||
let env = MapEnv::new()
|
||||
.with("HOME", "/home/u")
|
||||
.with("APPDATA", "C:/Users/u/AppData/Roaming")
|
||||
.with("LOCALAPPDATA", "C:/Users/u/AppData/Local")
|
||||
// 相对路径按 XDG 规范忽略。
|
||||
.with("XDG_CONFIG_HOME", "relative/dir");
|
||||
let roots = HostRoots::from_env(&env).expect("HOME 已给");
|
||||
assert_eq!(roots.app_data, PathBuf::from("C:/Users/u/AppData/Roaming"));
|
||||
assert_eq!(
|
||||
roots.local_app_data,
|
||||
PathBuf::from("C:/Users/u/AppData/Local")
|
||||
);
|
||||
assert_eq!(roots.config_home, PathBuf::from("/home/u/.config"));
|
||||
assert_eq!(roots.system_root, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_io_treats_non_zero_exit_as_failure() {
|
||||
// 有 stdout 但退出码非零:只看 stdout 的旧实现会把这种输出当成功。
|
||||
#[cfg(windows)]
|
||||
let (program, args) = ("cmd", ["/c", "echo hi & exit 3"]);
|
||||
#[cfg(not(windows))]
|
||||
let (program, args) = ("sh", ["-c", "echo hi; exit 3"]);
|
||||
assert!(HostIo.run(program, &args).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_io_serves_only_registered_world() {
|
||||
let io = fake::FakeIo::new()
|
||||
.with_file("/home/u/note.txt", "hello")
|
||||
.with_image("/home/u/a.png")
|
||||
.with_dir("/home/u/dir", &["a.png", "b.theme"])
|
||||
.with_command("gsettings get x y", "'file:///home/u/a.png'");
|
||||
assert_eq!(
|
||||
io.read_text(Path::new("/home/u/note.txt")).as_deref(),
|
||||
Some("hello")
|
||||
);
|
||||
assert_eq!(
|
||||
io.read_head(Path::new("/home/u/note.txt"), 4).as_deref(),
|
||||
Some(&b"hell"[..])
|
||||
);
|
||||
assert_eq!(
|
||||
io.read_head(Path::new("/home/u/a.png"), 8).as_deref(),
|
||||
Some(&fake::PNG_HEAD[..8])
|
||||
);
|
||||
assert_eq!(io.list_dir(Path::new("/home/u/dir")).len(), 2);
|
||||
assert!(io.run("gsettings", &["get", "x", "y"]).is_some());
|
||||
assert!(io.run("reg.exe", &["query"]).is_none());
|
||||
assert!(io.read_text(Path::new("/nope")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Linux 壁纸路径探测:GNOME → KDE Plasma → swaybg / hyprpaper → XFCE。
|
||||
//!
|
||||
//! 四步互相独立:`gsettings` 缺失、配置文件不存在、解析不到值都只记一条原因,探测继续往下
|
||||
//! 走(Wayland 上一条配置都读不到是常态,此时应直接落到 D38 的第③级色卡)。
|
||||
//!
|
||||
//! 探测路径全部来自「桌面环境自己记在磁盘上的东西」,不需要 D-Bus 与 xdg-desktop-portal:
|
||||
//!
|
||||
//! 1. GNOME / Ubuntu 系:`gsettings get org.gnome.desktop.background picture-uri`,再看
|
||||
//! `picture-uri-dark`(深色壁纸是独立键);
|
||||
//! 2. KDE Plasma:`~/.config/plasma-org.kde.plasma.desktop-appletsrc` 的 `Image=` 行;
|
||||
//! 3. swaybg / hyprpaper:`~/.config/hypr/hyprpaper.conf`(`wallpaper =` / `preload =`)与
|
||||
//! `~/.config/sway/config`(`output <name> bg <path>`);
|
||||
//! 4. XFCE:`~/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml` 的
|
||||
//! `last-image` / `image-path` 属性。
|
||||
//!
|
||||
//! 本文件不带 `cfg`:平台分流只在 [`super::detect_with`] 一处,任何宿主都能跑夹具用例。
|
||||
|
||||
use super::{Collect, SourceKind};
|
||||
|
||||
/// GNOME 壁纸 schema。
|
||||
pub(super) const GNOME_SCHEMA: &str = "org.gnome.desktop.background";
|
||||
|
||||
/// 探测的键,顺序即优先级(浅色在前,深色壁纸在后)。
|
||||
pub(super) const GNOME_KEYS: &[&str] = &["picture-uri", "picture-uri-dark"];
|
||||
|
||||
const KDE_CONFIG: &str = "plasma-org.kde.plasma.desktop-appletsrc";
|
||||
const HYPRPAPER_CONFIG: &str = "hypr/hyprpaper.conf";
|
||||
const SWAY_CONFIG: &str = "sway/config";
|
||||
const XFCE_DESKTOP_XML: &str = "xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml";
|
||||
|
||||
/// 四步依次探测。
|
||||
pub(super) fn collect(collect: &mut Collect<'_>) {
|
||||
gnome(collect);
|
||||
kde(collect);
|
||||
compositors(collect);
|
||||
xfce(collect);
|
||||
}
|
||||
|
||||
/// 第一步:GNOME 的 `picture-uri` / `picture-uri-dark`。
|
||||
fn gnome(collect: &mut Collect<'_>) {
|
||||
for key in GNOME_KEYS {
|
||||
let evidence = format!("gsettings get {GNOME_SCHEMA} {key}");
|
||||
match collect.io().run("gsettings", &["get", GNOME_SCHEMA, key]) {
|
||||
Some(text) => match parse_gsettings_value(&text) {
|
||||
Some(value) => {
|
||||
collect.add(SourceKind::Gnome, &evidence, &value);
|
||||
}
|
||||
None => collect.note(
|
||||
SourceKind::Gnome.as_str(),
|
||||
format!("{evidence} = {}(没有设壁纸)", text.trim()),
|
||||
),
|
||||
},
|
||||
None => collect.note(
|
||||
SourceKind::Gnome.as_str(),
|
||||
format!("{evidence} 失败(命令缺失 / 非零退出 / 空输出)"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 第二步:KDE Plasma 的壁纸插件配置。
|
||||
fn kde(collect: &mut Collect<'_>) {
|
||||
let Some(text) = read_config(collect, SourceKind::Kde, KDE_CONFIG) else {
|
||||
return;
|
||||
};
|
||||
let values = parse_kde_images(&text);
|
||||
if values.is_empty() {
|
||||
collect.note(
|
||||
SourceKind::Kde.as_str(),
|
||||
format!("{KDE_CONFIG} 里没有 Image= 行"),
|
||||
);
|
||||
}
|
||||
let evidence = format!("{KDE_CONFIG} 的 Image=");
|
||||
for value in values {
|
||||
collect.add(SourceKind::Kde, &evidence, &value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 第三步:swaybg(sway 配置)/ hyprpaper。
|
||||
fn compositors(collect: &mut Collect<'_>) {
|
||||
for (relative, what) in [
|
||||
(HYPRPAPER_CONFIG, "wallpaper/preload 行"),
|
||||
(SWAY_CONFIG, "output ... bg 行"),
|
||||
] {
|
||||
let Some(text) = read_config(collect, SourceKind::WlrootsCompositor, relative) else {
|
||||
continue;
|
||||
};
|
||||
let values = match relative {
|
||||
HYPRPAPER_CONFIG => parse_hyprpaper_wallpapers(&text),
|
||||
_ => parse_sway_backgrounds(&text),
|
||||
};
|
||||
if values.is_empty() {
|
||||
collect.note(
|
||||
SourceKind::WlrootsCompositor.as_str(),
|
||||
format!("{relative} 里没有 {what}"),
|
||||
);
|
||||
}
|
||||
let evidence = format!("{relative} 的 {what}");
|
||||
for value in values {
|
||||
collect.add(SourceKind::WlrootsCompositor, &evidence, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 第四步:XFCE 的 xfconf XML。
|
||||
fn xfce(collect: &mut Collect<'_>) {
|
||||
let Some(text) = read_config(collect, SourceKind::Xfce, XFCE_DESKTOP_XML) else {
|
||||
return;
|
||||
};
|
||||
let values = parse_xfce_last_image(&text);
|
||||
if values.is_empty() {
|
||||
collect.note(
|
||||
SourceKind::Xfce.as_str(),
|
||||
format!("{XFCE_DESKTOP_XML} 里没有 last-image/image-path 属性"),
|
||||
);
|
||||
}
|
||||
let evidence = format!("{XFCE_DESKTOP_XML} 的 last-image/image-path");
|
||||
for value in values {
|
||||
collect.add(SourceKind::Xfce, &evidence, &value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读 `$XDG_CONFIG_HOME` 下的一个配置文件;读不到只记原因并返回 `None`。
|
||||
fn read_config(collect: &mut Collect<'_>, kind: SourceKind, relative: &str) -> Option<String> {
|
||||
let path = collect.roots().config_home.join(relative);
|
||||
match collect.io().read_text(&path) {
|
||||
Some(text) => Some(text),
|
||||
None => {
|
||||
collect.note(kind.as_str(), format!("读不到 {}", path.display()));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 `gsettings get` 的输出:`'file:///home/u/a.png'`;`'none'` / 空串返回 `None`。
|
||||
pub(super) fn parse_gsettings_value(text: &str) -> Option<String> {
|
||||
let trimmed = text.trim();
|
||||
let value = trimmed
|
||||
.strip_prefix('\'')
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
.unwrap_or(trimmed)
|
||||
.trim();
|
||||
if value.is_empty() || value.eq_ignore_ascii_case("none") {
|
||||
return None;
|
||||
}
|
||||
Some(value.to_owned())
|
||||
}
|
||||
|
||||
/// 取 Plasma 配置里的 `Image=` 值(壁纸插件是 `[org.kde.image][General]` 段)。
|
||||
///
|
||||
/// 只收绝对路径与 `file://` URI:Plasma 的其它插件也有 `Image=` 键,相对值当成非壁纸。
|
||||
pub(super) fn parse_kde_images(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let (key, value) = line.trim().split_once('=')?;
|
||||
if !key.trim().eq_ignore_ascii_case("image") {
|
||||
return None;
|
||||
}
|
||||
let value = value.trim();
|
||||
let usable = value.starts_with('/')
|
||||
|| value.starts_with("file://")
|
||||
|| value
|
||||
.get(..2)
|
||||
.is_some_and(|head| head.eq_ignore_ascii_case("~/"));
|
||||
usable.then(|| value.to_owned())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 取 hyprpaper 的 `wallpaper = <监视器>,<路径>` 与 `preload = <路径>` 行。
|
||||
pub(super) fn parse_hyprpaper_wallpapers(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
let (key, value) = line.split_once('=')?;
|
||||
let value = value.trim();
|
||||
match key.trim().to_ascii_lowercase().as_str() {
|
||||
"preload" => non_empty(value),
|
||||
// `wallpaper = eDP-1,/path/a.png`(监视器可留空);没写逗号时整段就是路径。
|
||||
"wallpaper" => match value.split_once(',') {
|
||||
Some((_, path)) => non_empty(path),
|
||||
None => non_empty(value),
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 取 sway 配置里 `output <名字|*> bg <路径> [填充方式]` 的路径。
|
||||
///
|
||||
/// sway 允许用引号包住含空格的对象名/路径,这里把引号拼回来;解析不出来的行直接跳过。
|
||||
pub(super) fn parse_sway_backgrounds(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() < 4 || tokens[0] != "output" || tokens[2] != "bg" {
|
||||
return None;
|
||||
}
|
||||
let raw = tokens[3];
|
||||
let mut value = raw.trim_start_matches('"').to_owned();
|
||||
if raw.starts_with('"') && !raw.ends_with('"') {
|
||||
for token in &tokens[4..] {
|
||||
value.push(' ');
|
||||
match token.strip_suffix('"') {
|
||||
Some(tail) => {
|
||||
value.push_str(tail);
|
||||
break;
|
||||
}
|
||||
None => value.push_str(token),
|
||||
}
|
||||
}
|
||||
}
|
||||
non_empty(value.trim_end_matches('"'))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 取 XFCE `xfce4-desktop.xml` 里 `last-image` / `image-path` 属性的值。
|
||||
pub(super) fn parse_xfce_last_image(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.filter(|line| line.contains("last-image") || line.contains("image-path"))
|
||||
.filter_map(|line| non_empty(&attribute(line, "value")?))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 取 XML 一行里 `name="..."` 属性的值。
|
||||
fn attribute(line: &str, name: &str) -> Option<String> {
|
||||
let needle = format!("{name}=\"");
|
||||
let start = line.find(&needle)? + needle.len();
|
||||
let rest = &line[start..];
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_owned())
|
||||
}
|
||||
|
||||
fn non_empty(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::io::fake::FakeIo;
|
||||
use super::super::{detect_with, SourceKind};
|
||||
use super::*;
|
||||
use crate::platform::paths::{MapEnv, Platform};
|
||||
|
||||
const CONFIG: &str = "/home/u/.config";
|
||||
|
||||
fn env() -> MapEnv {
|
||||
MapEnv::new().with("HOME", "/home/u")
|
||||
}
|
||||
|
||||
fn gsettings(key: &str) -> String {
|
||||
format!("gsettings get {GNOME_SCHEMA} {key}")
|
||||
}
|
||||
|
||||
fn config(name: &str) -> String {
|
||||
format!("{CONFIG}/{name}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gnome_picture_uri_hit_decodes_percent_escapes() {
|
||||
let io = FakeIo::new()
|
||||
.with_command(
|
||||
&gsettings("picture-uri"),
|
||||
"'file:///home/u/my%20wall.png'\n",
|
||||
)
|
||||
.with_image("/home/u/my wall.png");
|
||||
let probe = detect_with(&io, &env(), Platform::Linux);
|
||||
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||
let best = probe.best().expect("有候选");
|
||||
assert_eq!(best.kind, SourceKind::Gnome);
|
||||
assert_eq!(best.path.display().to_string(), "/home/u/my wall.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_steps_yield_ordered_candidates() {
|
||||
let io = ["a", "b", "k", "h", "h2", "s", "x"]
|
||||
.into_iter()
|
||||
.map(|name| format!("/home/u/{name}.png"))
|
||||
.fold(
|
||||
FakeIo::new()
|
||||
.with_command(&gsettings("picture-uri"), "'file:///home/u/a.png'\n")
|
||||
.with_command(&gsettings("picture-uri-dark"), "'file:///home/u/b.png'\n")
|
||||
.with_file(config(KDE_CONFIG), "Image=file:///home/u/k.png\n")
|
||||
.with_file(
|
||||
config(HYPRPAPER_CONFIG),
|
||||
"preload = /home/u/h.png\nwallpaper = eDP-1, /home/u/h2.png\n",
|
||||
)
|
||||
.with_file(config(SWAY_CONFIG), "output * bg /home/u/s.png fill\n")
|
||||
.with_file(
|
||||
config(XFCE_DESKTOP_XML),
|
||||
"<property name=\"last-image\" value=\"/home/u/x.png\"/>\n",
|
||||
),
|
||||
FakeIo::with_image,
|
||||
);
|
||||
let probe = detect_with(&io, &env(), Platform::Linux);
|
||||
let seen: Vec<(SourceKind, String)> = probe
|
||||
.iter()
|
||||
.map(|source| (source.kind, source.path.display().to_string()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
(SourceKind::Gnome, "/home/u/a.png"),
|
||||
(SourceKind::Gnome, "/home/u/b.png"),
|
||||
(SourceKind::Kde, "/home/u/k.png"),
|
||||
(SourceKind::WlrootsCompositor, "/home/u/h.png"),
|
||||
(SourceKind::WlrootsCompositor, "/home/u/h2.png"),
|
||||
(SourceKind::WlrootsCompositor, "/home/u/s.png"),
|
||||
(SourceKind::Xfce, "/home/u/x.png"),
|
||||
]
|
||||
.map(|(kind, path)| (kind, path.to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failing_step_does_not_stop_later_steps() {
|
||||
// gsettings 说「没设壁纸」,KDE 指向一个不存在的文件,XFCE 才是真的。
|
||||
let io = FakeIo::new()
|
||||
.with_command(&gsettings("picture-uri"), "'none'\n")
|
||||
.with_file(config(KDE_CONFIG), "Image=file:///home/u/gone.png\n")
|
||||
.with_file(
|
||||
config(XFCE_DESKTOP_XML),
|
||||
"<property name=\"image-path\" value=\"/home/u/x.png\"/>\n",
|
||||
)
|
||||
.with_image("/home/u/x.png");
|
||||
let probe = detect_with(&io, &env(), Platform::Linux);
|
||||
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||
assert_eq!(probe.best().expect("有候选").kind, SourceKind::Xfce);
|
||||
assert!(
|
||||
probe.notes().iter().any(|note| note.contains("gone.png")),
|
||||
"落选原因要留:{:?}",
|
||||
probe.notes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_missing_returns_empty_with_reasons() {
|
||||
let probe = detect_with(&FakeIo::new(), &env(), Platform::Linux);
|
||||
assert!(probe.is_empty(), "全部失败要返回空列表");
|
||||
let notes = probe.notes().join("\n");
|
||||
assert!(notes.contains("gsettings"), "{notes}");
|
||||
assert!(notes.contains("xfce4-desktop.xml"), "{notes}");
|
||||
assert!(notes.contains("hyprpaper.conf"), "{notes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gsettings_output_parsing() {
|
||||
assert_eq!(
|
||||
parse_gsettings_value("'file:///home/u/a.png'\n"),
|
||||
Some("file:///home/u/a.png".to_owned())
|
||||
);
|
||||
assert_eq!(parse_gsettings_value("'none'\n"), None);
|
||||
assert_eq!(parse_gsettings_value("''"), None);
|
||||
assert_eq!(parse_gsettings_value(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_line_parsing() {
|
||||
assert_eq!(
|
||||
parse_kde_images("Image=file:///home/u/a.png\nImage=relative/b.png\n"),
|
||||
vec!["file:///home/u/a.png".to_owned()]
|
||||
);
|
||||
assert_eq!(
|
||||
parse_hyprpaper_wallpapers(
|
||||
"# 注释行\npreload = /home/u/a.png\nwallpaper = eDP-1, /home/u/b.png\nwallpaper = ,/home/u/c.png\nsplash = false\n"
|
||||
),
|
||||
vec![
|
||||
"/home/u/a.png".to_owned(),
|
||||
"/home/u/b.png".to_owned(),
|
||||
"/home/u/c.png".to_owned(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
parse_sway_backgrounds(
|
||||
"output * bg /home/u/a.png fill\noutput eDP-1 bg \"/home/u/b c.png\" stretch\n"
|
||||
),
|
||||
vec!["/home/u/a.png".to_owned(), "/home/u/b c.png".to_owned()]
|
||||
);
|
||||
assert_eq!(
|
||||
parse_xfce_last_image(
|
||||
"<property name=\"last-image\" value=\"/home/u/a.png\"/>\n<property name=\"image-style\" value=\"3\"/>\n"
|
||||
),
|
||||
vec!["/home/u/a.png".to_owned()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! macOS 壁纸路径探测:`com.apple.wallpaper` 的 `Index.plist` → 老式 `com.apple.desktop` 域。
|
||||
//!
|
||||
//! 两条通道:
|
||||
//!
|
||||
//! 1. `~/Library/Application Support/com.apple.wallpaper/Store/Index.plist`——Big Sur 之后
|
||||
//! 壁纸设置的真实落点,**二进制 plist(`bplist00`)**,里面记着
|
||||
//! `file:///…/xxx.heic` 与裸绝对路径;
|
||||
//! 2. `defaults read com.apple.desktop Background`——Catalina 及更早的每屏壁纸配置
|
||||
//! (`ImageFilePath = "/Library/Desktop Pictures/…"`),新系统上该域通常已不存在。
|
||||
//!
|
||||
//! # 未在真机验证
|
||||
//!
|
||||
//! 本机(Windows)与 CI(Linux)都没有 macOS 通道:**下列代码没有在 macOS 真机上跑过**。
|
||||
//! 因此这里刻意不引入 plist 解析依赖(`plist` crate 会为「读几个路径串」多背一个依赖),
|
||||
//! 改成手写的字符串扫描:plist 里的路径串本身是可读 ASCII,按「可打印 ASCII 连续段」切出来
|
||||
//! 再按「行首/分隔符后的 `/`」认路径开头即可,二进制 plist 的二进制部分天然成为分隔符。
|
||||
//! 扫出的碎片串由 [`super::Collect`] 的图片头校验兜掉,不会喂给取色器。
|
||||
//! 真机行为待后续 mission 在 macOS 上补充验证(`Index.plist` 的真实结构、`.heic` 壁纸路径)。
|
||||
|
||||
use super::{Collect, SourceKind};
|
||||
|
||||
/// Big Sur 之后的壁纸设置存储(相对用户主目录)。
|
||||
pub(super) const STORE_INDEX: &str =
|
||||
"Library/Application Support/com.apple.wallpaper/Store/Index.plist";
|
||||
|
||||
/// 老式壁纸配置域。
|
||||
pub(super) const DESKTOP_DOMAIN: &str = "com.apple.desktop";
|
||||
|
||||
/// 一次扫描最多收多少个候选串(防止异常文件把内存打满;正常情况下只有个位数)。
|
||||
const MAX_SCANNED: usize = 64;
|
||||
|
||||
/// 两步依次探测。
|
||||
pub(super) fn collect(collect: &mut Collect<'_>) {
|
||||
store_index(collect);
|
||||
desktop_defaults(collect);
|
||||
}
|
||||
|
||||
/// 第一步:壁纸 Store 的 `Index.plist`。
|
||||
fn store_index(collect: &mut Collect<'_>) {
|
||||
let path = collect.roots().home.join(STORE_INDEX);
|
||||
let Some(text) = collect.io().read_text(&path) else {
|
||||
collect.note(
|
||||
SourceKind::MacOsStore.as_str(),
|
||||
format!("读不到 {}(Catalina 及更早没有这个文件)", path.display()),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let values = scan_string_paths(&text);
|
||||
if values.is_empty() {
|
||||
collect.note(
|
||||
SourceKind::MacOsStore.as_str(),
|
||||
format!("{} 里没有路径串", path.display()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let evidence = "Library/Application Support/com.apple.wallpaper/Store/Index.plist 的路径串";
|
||||
for value in values {
|
||||
collect.add(SourceKind::MacOsStore, evidence, &value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 第二步:`defaults read com.apple.desktop Background`(老系统)。
|
||||
fn desktop_defaults(collect: &mut Collect<'_>) {
|
||||
let evidence = format!("defaults read {DESKTOP_DOMAIN} Background");
|
||||
match collect
|
||||
.io()
|
||||
.run("defaults", &["read", DESKTOP_DOMAIN, "Background"])
|
||||
{
|
||||
Some(text) => {
|
||||
let values = scan_defaults_background(&text);
|
||||
if values.is_empty() {
|
||||
collect.note(
|
||||
SourceKind::MacOsDesktopDefaults.as_str(),
|
||||
format!("{evidence} 里没有 ImageFilePath"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for value in values {
|
||||
collect.add(SourceKind::MacOsDesktopDefaults, &evidence, &value);
|
||||
}
|
||||
}
|
||||
None => collect.note(
|
||||
SourceKind::MacOsDesktopDefaults.as_str(),
|
||||
format!("{evidence} 失败(命令缺失 / 非零退出 / 该域不存在)"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 plist 原文里扫出候选串:`file://` URL 与裸绝对路径。
|
||||
///
|
||||
/// 输入既可能是 XML plist(`<string>file:///…</string>`)也可能是二进制 plist 的 lossy 文本
|
||||
/// (可读 ASCII 串之间夹着不可打印字节)。因此先按「可打印 ASCII 连续段」切,再在每段里认:
|
||||
///
|
||||
/// - 任意位置的 `file://` 开头;
|
||||
/// - 「段首 / 分隔符之后 / `~` 之后」的 `/` 开头(路径中间的 `/` 不算新起点,避免同一路径
|
||||
/// 被切成好几段)。
|
||||
#[must_use]
|
||||
pub(super) fn scan_string_paths(text: &str) -> Vec<String> {
|
||||
let mut found = Vec::new();
|
||||
for run in printable_runs(text) {
|
||||
let mut cursor = 0;
|
||||
while let Some(offset) = run[cursor..].find("file://") {
|
||||
let start = cursor + offset;
|
||||
push_candidate(&mut found, &run[start..]);
|
||||
cursor = start + "file://".len();
|
||||
}
|
||||
for (position, _) in run.match_indices('/') {
|
||||
let Some(start) = path_start(run, position) else {
|
||||
continue;
|
||||
};
|
||||
push_candidate(&mut found, &run[start..]);
|
||||
}
|
||||
if found.len() >= MAX_SCANNED {
|
||||
break;
|
||||
}
|
||||
}
|
||||
found.truncate(MAX_SCANNED);
|
||||
found
|
||||
}
|
||||
|
||||
/// 解析 `defaults read com.apple.desktop Background` 的文本:只认 `ImageFilePath` / `NewPath`。
|
||||
#[must_use]
|
||||
pub(super) fn scan_defaults_background(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
let key = key.trim().trim_matches('"');
|
||||
if !key.eq_ignore_ascii_case("ImageFilePath") && !key.eq_ignore_ascii_case("NewPath") {
|
||||
return None;
|
||||
}
|
||||
let value = value.trim().trim_end_matches(';').trim().trim_matches('"');
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 可打印 ASCII 连续段(二进制 plist 的二进制字节在这里变成分隔符)。
|
||||
fn printable_runs(text: &str) -> Vec<&str> {
|
||||
text.split(|ch: char| !(ch.is_ascii_graphic() || ch == ' '))
|
||||
.map(str::trim)
|
||||
.filter(|run| !run.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 判断 `run[position]` 的 `/` 是不是一个路径起点;是则返回该路径在 `run` 里的起点。
|
||||
fn path_start(run: &str, position: usize) -> Option<usize> {
|
||||
let next = run[position + 1..].chars().next()?;
|
||||
if !(next.is_ascii_alphanumeric() || next == '_') {
|
||||
return None;
|
||||
}
|
||||
match run[..position].chars().next_back() {
|
||||
// 段首:路径直接跟在二进制分隔符后面。
|
||||
None => Some(position),
|
||||
// `~/Library/...`:连 `~` 一起收,交给路径归一化展开。
|
||||
Some('~') => Some(position - 1),
|
||||
// 闭合标签 `</string>` 里的那个斜杠不是路径。
|
||||
Some('<') => None,
|
||||
// 路径中间的 `/`(前面还是路径字符)不当新起点。
|
||||
Some(prev) if is_path_char(prev) => None,
|
||||
Some(_) => Some(position),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_path_char(ch: char) -> bool {
|
||||
ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '%' | '+' | '@' | '~' | ' ')
|
||||
}
|
||||
|
||||
/// plist/XML 序列化里的终止字符(`<string>` 的尖括号、引号、实体)切掉。
|
||||
fn push_candidate(found: &mut Vec<String>, raw: &str) {
|
||||
let end = raw.find(['<', '>', '"', '&', '\'']).unwrap_or(raw.len());
|
||||
let candidate = raw[..end].trim_end();
|
||||
if !candidate.is_empty() && !found.iter().any(|seen| seen.as_str() == candidate) {
|
||||
found.push(candidate.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::io::fake::FakeIo;
|
||||
use super::super::{detect_with, SourceKind};
|
||||
use super::*;
|
||||
use crate::platform::paths::{MapEnv, Platform};
|
||||
|
||||
const STORE_PATH: &str =
|
||||
"/Users/me/Library/Application Support/com.apple.wallpaper/Store/Index.plist";
|
||||
const DEFAULTS_KEY: &str = "defaults read com.apple.desktop Background";
|
||||
|
||||
fn env() -> MapEnv {
|
||||
MapEnv::new().with("HOME", "/Users/me")
|
||||
}
|
||||
|
||||
fn xml_plist(body: &str) -> String {
|
||||
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\">\n{body}\n</plist>\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_index_hit_decodes_file_url() {
|
||||
let io = FakeIo::new()
|
||||
.with_file(
|
||||
STORE_PATH,
|
||||
&xml_plist("<string>file:///Users/me/Pictures/my%20wall.heic</string>"),
|
||||
)
|
||||
.with_image("/Users/me/Pictures/my wall.heic");
|
||||
let probe = detect_with(&io, &env(), Platform::MacOs);
|
||||
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||
let best = probe.best().expect("有候选");
|
||||
assert_eq!(best.kind, SourceKind::MacOsStore);
|
||||
assert_eq!(
|
||||
best.path,
|
||||
std::path::PathBuf::from("/Users/me/Pictures/my wall.heic")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_candidates_from_both_channels_keep_order() {
|
||||
let io = FakeIo::new()
|
||||
.with_file(
|
||||
STORE_PATH,
|
||||
&xml_plist(
|
||||
"<array>\n\
|
||||
<string>file:///System/Library/Desktop Pictures/a.heic</string>\n\
|
||||
<string>/Users/me/Library/Application Support/com.apple.wallpaper/Store/b.heic</string>\n\
|
||||
</array>",
|
||||
),
|
||||
)
|
||||
.with_image("/System/Library/Desktop Pictures/a.heic")
|
||||
.with_image(
|
||||
"/Users/me/Library/Application Support/com.apple.wallpaper/Store/b.heic",
|
||||
)
|
||||
.with_image("/Library/Desktop Pictures/legacy.jpg")
|
||||
.with_command(
|
||||
DEFAULTS_KEY,
|
||||
"{\n \"Display Identifier\" = \"Main\";\n \
|
||||
ImageFilePath = \"/Library/Desktop Pictures/legacy.jpg\";\n}\n",
|
||||
);
|
||||
let probe = detect_with(&io, &env(), Platform::MacOs);
|
||||
let kinds: Vec<SourceKind> = probe.iter().map(|source| source.kind).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![
|
||||
SourceKind::MacOsStore,
|
||||
SourceKind::MacOsStore,
|
||||
SourceKind::MacOsDesktopDefaults,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
probe.best().expect("有候选").path,
|
||||
std::path::PathBuf::from("/System/Library/Desktop Pictures/a.heic")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_missing_returns_empty_with_reasons() {
|
||||
let probe = detect_with(&FakeIo::new(), &env(), Platform::MacOs);
|
||||
assert!(probe.is_empty(), "全部失败要返回空列表");
|
||||
let notes = probe.notes().join("\n");
|
||||
assert!(notes.contains("Index.plist"), "{notes}");
|
||||
assert!(notes.contains("defaults"), "{notes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_plist_strings_are_scanned_between_binary_bytes() {
|
||||
// bplist00 的形态:控制字符/长度字节把可读 ASCII 串隔开。
|
||||
let raw = "\u{0}\u{1}\u{2}/Users/me/Pictures/a.heic\u{0}\u{3}file:///Users/me/b.jpg\u{0}";
|
||||
let found = scan_string_paths(raw);
|
||||
assert_eq!(
|
||||
found,
|
||||
vec![
|
||||
"/Users/me/Pictures/a.heic".to_owned(),
|
||||
"file:///Users/me/b.jpg".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_tags_are_not_part_of_the_candidate() {
|
||||
let found = scan_string_paths(&xml_plist(
|
||||
"<key>Content</key>\n<string>/Users/me/a.png</string>",
|
||||
));
|
||||
assert_eq!(found, vec!["/Users/me/a.png".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_output_parsing() {
|
||||
assert_eq!(
|
||||
scan_defaults_background(
|
||||
"{\n \"Display Identifier\" = \"Main\";\n \
|
||||
ImageFilePath = \"/Library/Desktop Pictures/a.jpg\";\n \
|
||||
NewPath = \"/Users/me/b.png\";\n}\n"
|
||||
),
|
||||
vec![
|
||||
"/Library/Desktop Pictures/a.jpg".to_owned(),
|
||||
"/Users/me/b.png".to_owned(),
|
||||
]
|
||||
);
|
||||
assert!(scan_defaults_background("{\n BackgroundColor = \"0 0 0\";\n}\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_paths_keep_their_prefix() {
|
||||
let found = scan_string_paths("<string>~/Library/Desktop Pictures/a.heic</string>");
|
||||
assert_eq!(found, vec!["~/Library/Desktop Pictures/a.heic".to_owned()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//! 路径归一化与图片头嗅探:把各平台读出的「原始串」变成可用的绝对路径候选。
|
||||
//!
|
||||
//! 三平台读出来的东西形状都不一样(gsettings 的 `'file:///x'`、KDE 的 `file:///x`、
|
||||
//! Windows `.theme` 的 `%SystemRoot%\x`、plist 里裸的 `/x`),全部在这里收口,
|
||||
//! 上层子模块只管「从哪读」。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::io::HostRoots;
|
||||
|
||||
/// 图片文件头识别(不用解码库,只看魔数与文本 SVG 头)。
|
||||
///
|
||||
/// 认得的:PNG / JPEG / GIF / BMP / WebP / HEIF-AVIF / TIFF,外加头部出现 `<svg` 的文本。
|
||||
/// 认不出的一律不收——壁纸探测宁可少一个候选,也不要把随便一个文件喂给取色器。
|
||||
#[must_use]
|
||||
pub(super) fn looks_like_image(head: &[u8]) -> bool {
|
||||
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n";
|
||||
if head.starts_with(PNG) {
|
||||
return true;
|
||||
}
|
||||
if head.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
return true; // JPEG
|
||||
}
|
||||
if head.starts_with(b"GIF87a") || head.starts_with(b"GIF89a") {
|
||||
return true;
|
||||
}
|
||||
if head.len() >= 14 && head.starts_with(b"BM") {
|
||||
return true; // BMP
|
||||
}
|
||||
if head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"WEBP" {
|
||||
return true;
|
||||
}
|
||||
if head.len() >= 12 && &head[4..8] == b"ftyp" {
|
||||
// ISO-BMFF 家族:HEIC / HEIF 是 macOS 的默认壁纸容器(`.heic`),AVIF 是新格式。
|
||||
let brand = &head[8..12];
|
||||
return matches!(
|
||||
brand,
|
||||
b"avif" | b"avis" | b"heic" | b"heix" | b"mif1" | b"msf1"
|
||||
);
|
||||
}
|
||||
if head.starts_with(b"II*\0") || head.starts_with(b"MM\0*") {
|
||||
return true; // TIFF
|
||||
}
|
||||
contains_ignore_ascii_case(head, b"<svg")
|
||||
}
|
||||
|
||||
/// 把一条原始串归一化成绝对路径;不像壁纸路径的返回 `None`(具体原因由调用方记)。
|
||||
///
|
||||
/// 处理顺序:去引号 → 认 `file://` URI(含百分号解码与 `/C:/` 盘符还原)→ 展开
|
||||
/// `%USERPROFILE%` 之类的 Windows 变量 → 展开 `~/` → 要求绝对路径。
|
||||
#[must_use]
|
||||
pub(super) fn normalize_path(raw: &str, roots: &HostRoots) -> Option<PathBuf> {
|
||||
let unquoted = unquote(raw).trim();
|
||||
if unquoted.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// GNOME 的「无壁纸」与 macOS `defaults` 的「未设置」占位值。
|
||||
if unquoted.eq_ignore_ascii_case("none") || unquoted.eq_ignore_ascii_case("default") {
|
||||
return None;
|
||||
}
|
||||
let text = match strip_prefix_ignore_ascii_case(unquoted, "file://") {
|
||||
Some(rest) => strip_uri_drive_prefix(&percent_decode(rest)),
|
||||
None => expand_vars(unquoted, roots),
|
||||
};
|
||||
let text = text.trim();
|
||||
if let Some(rest) = text.strip_prefix("~/") {
|
||||
return Some(roots.home.join(rest));
|
||||
}
|
||||
if !looks_absolute(text) {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(text))
|
||||
}
|
||||
|
||||
/// 去掉成对的单/双引号(`gsettings get` 的输出是 `'file:///x'`)。
|
||||
fn unquote(raw: &str) -> &str {
|
||||
let trimmed = raw.trim();
|
||||
let bytes = trimmed.as_bytes();
|
||||
if bytes.len() >= 2 {
|
||||
let first = bytes[0];
|
||||
if (first == b'\'' || first == b'"') && bytes[bytes.len() - 1] == first {
|
||||
return &trimmed[1..trimmed.len() - 1];
|
||||
}
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
fn strip_prefix_ignore_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
let head = text.get(..prefix.len())?;
|
||||
head.eq_ignore_ascii_case(prefix)
|
||||
.then(|| &text[prefix.len()..])
|
||||
}
|
||||
|
||||
/// `file://` URI 的路径部分:`/C:/Users/me/a.jpg` 在 Windows 上是 `C:/Users/me/a.jpg`。
|
||||
///
|
||||
/// 非 Windows 上这条分支不会命中(Unix 路径不会以 `/C:` 开头),统一处理不影响正确性。
|
||||
fn strip_uri_drive_prefix(text: &str) -> String {
|
||||
let bytes = text.as_bytes();
|
||||
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' {
|
||||
text[1..].to_owned()
|
||||
} else {
|
||||
text.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// 百分号解码(GNOME 会把空格编码成 `%20`;非法转义原样保留)。
|
||||
fn percent_decode(text: &str) -> String {
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'%'
|
||||
&& index + 2 < bytes.len()
|
||||
&& bytes[index + 1].is_ascii_hexdigit()
|
||||
&& bytes[index + 2].is_ascii_hexdigit()
|
||||
{
|
||||
let hex = &text[index + 1..index + 3];
|
||||
if let Ok(byte) = u8::from_str_radix(hex, 16) {
|
||||
out.push(byte);
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// 展开 Windows 路径里的 `%VAR%`(`.theme` 的 `Wallpaper=%SystemRoot%\web\...`)。
|
||||
///
|
||||
/// 认不出的变量原样保留,让后面的图片校验把它判掉——这里不猜路径。
|
||||
fn expand_vars(text: &str, roots: &HostRoots) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find('%') {
|
||||
let Some(offset) = rest[start + 1..].find('%') else {
|
||||
break;
|
||||
};
|
||||
let name = &rest[start + 1..start + 1 + offset];
|
||||
let value = match name.to_ascii_lowercase().as_str() {
|
||||
"userprofile" | "home" => Some(roots.home.to_string_lossy()),
|
||||
"appdata" => Some(roots.app_data.to_string_lossy()),
|
||||
// Win10+ 的「当前主题」`Custom.theme` 在 `%LOCALAPPDATA%` 一侧。
|
||||
"localappdata" => Some(roots.local_app_data.to_string_lossy()),
|
||||
"systemroot" | "windir" => roots
|
||||
.system_root
|
||||
.as_deref()
|
||||
.map(std::path::Path::to_string_lossy),
|
||||
_ => None,
|
||||
};
|
||||
match value {
|
||||
Some(value) => {
|
||||
out.push_str(&rest[..start]);
|
||||
out.push_str(&value);
|
||||
rest = &rest[start + offset + 2..];
|
||||
}
|
||||
None => {
|
||||
out.push_str(&rest[..=start]);
|
||||
rest = &rest[start + 1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// 字符串层面判绝对路径:`/x`、`\\server\share`、`C:/x`、`C:\x`。
|
||||
///
|
||||
/// 不用 `Path::is_absolute` 是因为它按**编译目标**判,Windows 夹具在 Linux CI 上会被判成
|
||||
/// 相对路径,夹具就不跨宿主了。
|
||||
fn looks_absolute(text: &str) -> bool {
|
||||
let bytes = text.as_bytes();
|
||||
if text.starts_with('/') || text.starts_with('\\') {
|
||||
return true;
|
||||
}
|
||||
bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& matches!(bytes[2], b'/' | b'\\')
|
||||
}
|
||||
|
||||
fn contains_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
if needle.is_empty() || haystack.len() < needle.len() {
|
||||
return false;
|
||||
}
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.any(|window| window.eq_ignore_ascii_case(needle))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::platform::paths::MapEnv;
|
||||
|
||||
fn roots() -> HostRoots {
|
||||
let env = MapEnv::new()
|
||||
.with("HOME", "/home/u")
|
||||
.with("APPDATA", "C:/Users/u/AppData/Roaming")
|
||||
.with("SYSTEMROOT", "C:/Windows");
|
||||
HostRoots::from_env(&env).expect("HOME 已给")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_sniff_accepts_common_wallpaper_containers() {
|
||||
assert!(looks_like_image(b"\x89PNG\r\n\x1a\n\x00\x00"));
|
||||
assert!(looks_like_image(b"\xFF\xD8\xFF\xE0\x00\x10JFIF"));
|
||||
assert!(looks_like_image(b"GIF89a---"));
|
||||
assert!(looks_like_image(
|
||||
b"BM\x36\x00\x00\x00\x00\x00\x00\x00\x36\x00\x00\x00"
|
||||
));
|
||||
assert!(looks_like_image(b"RIFF\x24\x00\x00\x00WEBPVP8 "));
|
||||
assert!(looks_like_image(
|
||||
b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00heicmif1"
|
||||
));
|
||||
assert!(looks_like_image(b"II*\x00\x08\x00\x00\x00"));
|
||||
assert!(looks_like_image(
|
||||
b"<?xml version=\"1.0\"?><svg xmlns=\"...\""
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_sniff_rejects_non_images_and_short_heads() {
|
||||
assert!(!looks_like_image(b""));
|
||||
assert!(!looks_like_image(b"BM"));
|
||||
assert!(!looks_like_image(
|
||||
b"[Control Panel\\Desktop]\r\nWallpaper=x\r\n"
|
||||
));
|
||||
assert!(!looks_like_image(b"RIFF\x24\x00\x00\x00AVI LIST"));
|
||||
assert!(!looks_like_image(b"\x00\x00\x00\x18ftypmp42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_handles_file_uris_quotes_and_percent_encoding() {
|
||||
let roots = roots();
|
||||
assert_eq!(
|
||||
normalize_path("'file:///home/u/Pictures/my%20wall.png'", &roots),
|
||||
Some(PathBuf::from("/home/u/Pictures/my wall.png"))
|
||||
);
|
||||
// Windows 的 URI 会多一个前导斜杠,盘符要还原。
|
||||
assert_eq!(
|
||||
normalize_path("file:///C:/Users/u/a.jpg", &roots),
|
||||
Some(PathBuf::from("C:/Users/u/a.jpg"))
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_path("\"C:\\Users\\u\\a.jpg\"", &roots),
|
||||
Some(PathBuf::from("C:\\Users\\u\\a.jpg"))
|
||||
);
|
||||
// 非法百分号转义原样保留,不 panic(多字节字符也要安全)。
|
||||
assert_eq!(
|
||||
normalize_path("file:///home/u/100%zz%日.png", &roots),
|
||||
Some(PathBuf::from("/home/u/100%zz%日.png"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_expands_windows_vars_and_home() {
|
||||
let roots = roots();
|
||||
assert_eq!(
|
||||
normalize_path(r"%SystemRoot%\web\wallpaper\a.jpg", &roots),
|
||||
Some(PathBuf::from("C:/Windows\\web\\wallpaper\\a.jpg"))
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_path(
|
||||
"%APPDATA%\\Microsoft\\Windows\\Themes\\TranscodedWallpaper",
|
||||
&roots
|
||||
),
|
||||
Some(PathBuf::from(
|
||||
"C:/Users/u/AppData/Roaming\\Microsoft\\Windows\\Themes\\TranscodedWallpaper"
|
||||
))
|
||||
);
|
||||
// Win10+ 的当前主题在 %LOCALAPPDATA%,展开失败就会白丢最可靠的候选。
|
||||
assert_eq!(
|
||||
normalize_path(
|
||||
"%LOCALAPPDATA%\\Microsoft\\Windows\\Themes\\Custom.theme",
|
||||
&roots
|
||||
),
|
||||
Some(PathBuf::from(
|
||||
"/home/u/AppData/Local\\Microsoft\\Windows\\Themes\\Custom.theme"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_path("~/Library/Desktop Pictures/a.heic", &roots),
|
||||
Some(PathBuf::from("/home/u/Library/Desktop Pictures/a.heic"))
|
||||
);
|
||||
// 认不出的变量不猜:整串保留,随后被「必须绝对路径」这条校验判掉。
|
||||
assert_eq!(normalize_path("%NOPE%\\a.jpg", &roots), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rejects_placeholders_relative_and_empty() {
|
||||
let roots = roots();
|
||||
for raw in [
|
||||
"",
|
||||
" ",
|
||||
"none",
|
||||
"'none'",
|
||||
"default",
|
||||
"relative/a.png",
|
||||
"./a.png",
|
||||
] {
|
||||
assert_eq!(
|
||||
normalize_path(raw, &roots),
|
||||
None,
|
||||
"{raw:?} 不该被当成壁纸路径"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_detection_is_host_independent() {
|
||||
assert!(looks_absolute("/home/u/a.png"));
|
||||
assert!(looks_absolute(r"\\server\share\a.png"));
|
||||
assert!(looks_absolute("C:/Users/u/a.png"));
|
||||
assert!(looks_absolute(r"C:\Users\u\a.png"));
|
||||
assert!(!looks_absolute("a.png"));
|
||||
assert!(!looks_absolute("C:"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Windows 壁纸路径探测:四步,可信度从高到低。
|
||||
//!
|
||||
//! Win10+ 的真机事实(Windows 11 24H2 实测):`%APPDATA%\Microsoft\Windows\Themes\` 里
|
||||
//! **通常只有** `TranscodedWallpaper`,一个 `.theme` 都没有;当前生效的主题记在
|
||||
//! `HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme` 值里,指向
|
||||
//! `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`。所以「扫 `%APPDATA%` 下的
|
||||
//! `*.theme`」只能当最后一档兜底:那里的主题包是用户另存的,**可能从未启用**,拿它取色会
|
||||
//! 取到与当前桌面无关的图(宁可少给一个候选,也不给错路径)。
|
||||
//!
|
||||
//! 1. `%APPDATA%\Microsoft\Windows\Themes\TranscodedWallpaper`——系统**当前**壁纸转码后的
|
||||
//! 副本,没有扩展名(部分版本还有 `TranscodedWallpaper.jpg` 别名),所以「像不像图片」
|
||||
//! 只看文件头魔数,不看后缀;
|
||||
//! 2. `CurrentTheme` 指向的 `.theme`——当前生效主题里的 `Wallpaper=` 行(Win10+ 在
|
||||
//! `%LOCALAPPDATA%` 一侧,支持 `%LOCALAPPDATA%` / `%SystemRoot%` 之类变量展开);
|
||||
//! 3. `HKCU\Control Panel\Desktop` 的 `WallPaper` 值——注册表权威来源;
|
||||
//! 4. `%APPDATA%\...\Themes\*.theme`——用户另存的主题包,`evidence` 里如实标「可能未启用」。
|
||||
//!
|
||||
//! 第 2、3 步走 `reg.exe` 子进程,不用 win32 API(core 是 `#![forbid(unsafe_code)]`,
|
||||
//! 见 [`super`] 模块文档);命令缺失 / 非零退出 / 空输出都只记一条原因,探测继续。
|
||||
//!
|
||||
//! 本文件不带 `cfg`:Linux CI 上同样要能跑夹具用例,平台分流只在
|
||||
//! [`super::detect_with`] 一处。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use self::parse::{parse_reg_sz_value, parse_theme_wallpaper};
|
||||
use super::io::HostRoots;
|
||||
use super::parse::normalize_path;
|
||||
use super::{Collect, SourceKind};
|
||||
|
||||
mod parse;
|
||||
|
||||
/// `reg query` 用的桌面键(`HKCU\Control Panel\Desktop`,壁纸值名 `WallPaper`)。
|
||||
pub(super) const DESKTOP_KEY: &str = r"HKCU\Control Panel\Desktop";
|
||||
|
||||
/// `reg query` 用的主题键(Win10+ 当前主题记录在这里)。
|
||||
pub(super) const THEMES_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes";
|
||||
|
||||
/// 壁纸值名(注册表里是 `WallPaper`,`.theme` 里是 `Wallpaper=`,比对时都忽略大小写)。
|
||||
pub(super) const WALLPAPER_VALUE: &str = "WallPaper";
|
||||
|
||||
/// 当前主题文件名(值是指向 `.theme` 的绝对路径)。
|
||||
pub(super) const CURRENT_THEME_VALUE: &str = "CurrentTheme";
|
||||
|
||||
/// `%APPDATA%` 下的主题目录(分段拼,避免 Windows 上出现混用的分隔符)。
|
||||
const THEMES_PARTS: &[&str] = &["Microsoft", "Windows", "Themes"];
|
||||
|
||||
/// 转码副本的两个已知文件名(无扩展名的是主力)。
|
||||
const TRANSCODED_NAMES: &[&str] = &["TranscodedWallpaper", "TranscodedWallpaper.jpg"];
|
||||
|
||||
/// 主题文件后缀。
|
||||
const THEME_EXTENSION: &str = "theme";
|
||||
|
||||
/// 四步依次探测。
|
||||
pub(super) fn collect(collect: &mut Collect<'_>) {
|
||||
transcoded(collect);
|
||||
current_theme(collect);
|
||||
registry(collect);
|
||||
saved_theme_packages(collect);
|
||||
}
|
||||
|
||||
fn themes_dir(roots: &HostRoots) -> PathBuf {
|
||||
let mut dir = roots.app_data.clone();
|
||||
for part in THEMES_PARTS {
|
||||
dir.push(part);
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
/// 第一步:系统转码后的当前壁纸副本(第一个名字命中后就不再试别名)。
|
||||
fn transcoded(collect: &mut Collect<'_>) {
|
||||
let dir = themes_dir(collect.roots());
|
||||
for name in TRANSCODED_NAMES {
|
||||
let path = dir.join(name);
|
||||
let evidence = format!("%APPDATA%\\Microsoft\\Windows\\Themes\\{name}");
|
||||
if collect.add_path(SourceKind::WindowsTranscoded, &evidence, path) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 第二步:当前生效主题(`CurrentTheme` 指向的 `.theme`)里的 `Wallpaper=`。
|
||||
fn current_theme(collect: &mut Collect<'_>) {
|
||||
let Some(raw) = query_reg(
|
||||
collect,
|
||||
SourceKind::WindowsCurrentTheme,
|
||||
THEMES_KEY,
|
||||
CURRENT_THEME_VALUE,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let Some(theme) = normalize_path(&raw, collect.roots()) else {
|
||||
collect.note(
|
||||
SourceKind::WindowsCurrentTheme.as_str(),
|
||||
format!("{CURRENT_THEME_VALUE} 不是可用的绝对路径({raw})"),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let label = format!(
|
||||
"reg query {THEMES_KEY} /v {CURRENT_THEME_VALUE} → {}",
|
||||
theme.display()
|
||||
);
|
||||
collect_theme_file(collect, SourceKind::WindowsCurrentTheme, &theme, &label);
|
||||
}
|
||||
|
||||
/// 第三步:注册表里的权威壁纸值。
|
||||
fn registry(collect: &mut Collect<'_>) {
|
||||
let Some(value) = query_reg(
|
||||
collect,
|
||||
SourceKind::WindowsRegistry,
|
||||
DESKTOP_KEY,
|
||||
WALLPAPER_VALUE,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let label = format!("reg query {DESKTOP_KEY} /v {WALLPAPER_VALUE}");
|
||||
collect.add(SourceKind::WindowsRegistry, &label, &value);
|
||||
}
|
||||
|
||||
/// 第四步(兜底):`%APPDATA%\...\Themes\*.theme`——用户另存的主题包,**可能未启用**。
|
||||
fn saved_theme_packages(collect: &mut Collect<'_>) {
|
||||
let dir = themes_dir(collect.roots());
|
||||
let mut themes: Vec<PathBuf> = collect
|
||||
.io()
|
||||
.list_dir(&dir)
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
path.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case(THEME_EXTENSION))
|
||||
})
|
||||
.collect();
|
||||
themes.sort();
|
||||
if themes.is_empty() {
|
||||
collect.note(
|
||||
SourceKind::WindowsThemeFile.as_str(),
|
||||
format!("{} 下没有 .theme 文件", dir.display()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for theme in themes {
|
||||
let label = format!("{}(用户另存的主题包,可能未启用)", theme.display());
|
||||
collect_theme_file(collect, SourceKind::WindowsThemeFile, &theme, &label);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读一个 `.theme` 文件,把里面的 `Wallpaper=` 收成候选;读不到 / 没值只记原因。
|
||||
fn collect_theme_file(collect: &mut Collect<'_>, kind: SourceKind, file: &Path, label: &str) {
|
||||
let Some(text) = collect.io().read_text(file) else {
|
||||
collect.note(kind.as_str(), format!("读不到 {}", file.display()));
|
||||
return;
|
||||
};
|
||||
let values = parse_theme_wallpaper(&text);
|
||||
if values.is_empty() {
|
||||
collect.note(
|
||||
kind.as_str(),
|
||||
format!("{} 里没有 Wallpaper= 行", file.display()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let evidence = format!("{label} 的 Wallpaper=");
|
||||
for value in values {
|
||||
collect.add(kind, &evidence, &value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑一次 `reg query <键> /v <值名>` 并取出值;失败或缺值都只记原因、返回 `None`。
|
||||
fn query_reg(
|
||||
collect: &mut Collect<'_>,
|
||||
kind: SourceKind,
|
||||
key: &str,
|
||||
value_name: &str,
|
||||
) -> Option<String> {
|
||||
let query = format!("reg query {key} /v {value_name}");
|
||||
match collect
|
||||
.io()
|
||||
.run("reg.exe", &["query", key, "/v", value_name])
|
||||
{
|
||||
Some(text) => match parse_reg_sz_value(&text, value_name) {
|
||||
Some(value) => Some(value),
|
||||
None => {
|
||||
collect.note(
|
||||
kind.as_str(),
|
||||
format!("{query} 输出里没有 {value_name} 值(可能被策略禁用或壁纸为纯色)"),
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
collect.note(
|
||||
kind.as_str(),
|
||||
format!("{query} 失败(命令缺失 / 非零退出 / 空输出)"),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::io::fake::FakeIo;
|
||||
use super::super::{detect_with, SourceKind};
|
||||
use super::*;
|
||||
use crate::platform::paths::{MapEnv, Platform};
|
||||
|
||||
const APP_DATA: &str = "C:/Users/u/AppData/Roaming";
|
||||
const LOCAL_APP_DATA: &str = "C:/Users/u/AppData/Local";
|
||||
const DESKTOP_QUERY: &str = "reg.exe query HKCU\\Control Panel\\Desktop /v WallPaper";
|
||||
const THEMES_QUERY: &str =
|
||||
"reg.exe query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes /v CurrentTheme";
|
||||
const CUSTOM_THEME: &str = r"C:\Users\u\AppData\Local\Microsoft\Windows\Themes\Custom.theme";
|
||||
|
||||
fn env() -> MapEnv {
|
||||
MapEnv::new()
|
||||
.with("HOME", "C:/Users/u")
|
||||
.with("APPDATA", APP_DATA)
|
||||
.with("LOCALAPPDATA", LOCAL_APP_DATA)
|
||||
.with("SYSTEMROOT", "C:/Windows")
|
||||
}
|
||||
|
||||
fn roots() -> HostRoots {
|
||||
HostRoots::from_env(&env()).expect("HOME 已给")
|
||||
}
|
||||
|
||||
/// 夹具用**生产代码同一个拼法**造路径,所以 Windows / Linux 宿主上都能对上
|
||||
/// (另有一条字面量断言盯着这个拼法本身,见 `transcoded_wallpaper_hit_comes_first`)。
|
||||
fn themes() -> PathBuf {
|
||||
themes_dir(&roots())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcoded_wallpaper_hit_comes_first() {
|
||||
let io = FakeIo::new().with_image(themes().join("TranscodedWallpaper"));
|
||||
let probe = detect_with(&io, &env(), Platform::Windows);
|
||||
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||
let best = probe.best().expect("有候选");
|
||||
assert_eq!(best.kind, SourceKind::WindowsTranscoded);
|
||||
assert_eq!(best.path, themes().join("TranscodedWallpaper"));
|
||||
// 字面量:夹具路径是拿生产代码的构造函数造出来的,这条断言防止两边一起拼错。
|
||||
assert_eq!(
|
||||
best.path,
|
||||
PathBuf::from(
|
||||
"C:/Users/u/AppData/Roaming/Microsoft/Windows/Themes/TranscodedWallpaper"
|
||||
)
|
||||
);
|
||||
assert!(best.evidence.contains("TranscodedWallpaper"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_theme_is_read_from_local_app_data() {
|
||||
// Win10+ 的真机形态:%APPDATA% 下没有 .theme,当前主题在 %LOCALAPPDATA%。
|
||||
let io = FakeIo::new()
|
||||
.with_command(
|
||||
THEMES_QUERY,
|
||||
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
|
||||
)
|
||||
.with_file(
|
||||
CUSTOM_THEME,
|
||||
"[Control Panel\\Desktop]\r\nWallpaper=%SystemRoot%\\web\\wallpaper\\Windows\\img0.jpg\r\n",
|
||||
)
|
||||
.with_image("C:/Windows\\web\\wallpaper\\Windows\\img0.jpg");
|
||||
let probe = detect_with(&io, &env(), Platform::Windows);
|
||||
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||
let best = probe.best().expect("有候选");
|
||||
assert_eq!(best.kind, SourceKind::WindowsCurrentTheme);
|
||||
assert_eq!(
|
||||
best.path,
|
||||
PathBuf::from("C:/Windows\\web\\wallpaper\\Windows\\img0.jpg")
|
||||
);
|
||||
assert!(best.evidence.contains("CurrentTheme"), "{}", best.evidence);
|
||||
assert!(best.evidence.contains("Windows\\Themes\\Custom.theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_theme_expands_local_app_data_env_var() {
|
||||
let io = FakeIo::new()
|
||||
.with_command(
|
||||
THEMES_QUERY,
|
||||
" CurrentTheme REG_SZ %LOCALAPPDATA%\\Microsoft\\Windows\\Themes\\Custom.theme\r\n",
|
||||
)
|
||||
.with_file(
|
||||
CUSTOM_THEME,
|
||||
"Wallpaper=C:\\Users\\u\\Pictures\\a.jpg\r\n",
|
||||
)
|
||||
.with_image(r"C:\Users\u\Pictures\a.jpg");
|
||||
let probe = detect_with(&io, &env(), Platform::Windows);
|
||||
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
|
||||
assert_eq!(
|
||||
probe.best().expect("有候选").kind,
|
||||
SourceKind::WindowsCurrentTheme
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authority_order_puts_saved_packages_last() {
|
||||
// 四步全在场,路径互不相同:顺序必须是 转码副本 > 当前主题 > 注册表 > 另存包。
|
||||
let package = themes().join("a.theme");
|
||||
let io = FakeIo::new()
|
||||
.with_image(themes().join("TranscodedWallpaper"))
|
||||
.with_command(
|
||||
THEMES_QUERY,
|
||||
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
|
||||
)
|
||||
.with_file(
|
||||
CUSTOM_THEME,
|
||||
"Wallpaper=C:\\Users\\u\\Pictures\\one.jpg\r\n",
|
||||
)
|
||||
.with_command(
|
||||
DESKTOP_QUERY,
|
||||
" WallPaper REG_SZ C:\\Users\\u\\Pictures\\two.jpg\r\n",
|
||||
)
|
||||
.with_dir(themes(), &["a.theme", "b.theme", "notes.txt"])
|
||||
.with_file(
|
||||
package.clone(),
|
||||
"Wallpaper=%SystemRoot%\\web\\wallpaper\\three.jpg\r\n",
|
||||
)
|
||||
.with_image(r"C:\Users\u\Pictures\one.jpg")
|
||||
.with_image(r"C:\Users\u\Pictures\two.jpg")
|
||||
.with_image("C:/Windows\\web\\wallpaper\\three.jpg");
|
||||
let probe = detect_with(&io, &env(), Platform::Windows);
|
||||
let seen: Vec<(SourceKind, String)> = probe
|
||||
.iter()
|
||||
.map(|source| (source.kind, source.path.display().to_string()))
|
||||
.collect();
|
||||
assert_eq!(seen.len(), 4, "{:?}", probe.notes());
|
||||
assert_eq!(seen[0].0, SourceKind::WindowsTranscoded);
|
||||
assert_eq!(seen[1].0, SourceKind::WindowsCurrentTheme);
|
||||
assert_eq!(seen[2].0, SourceKind::WindowsRegistry);
|
||||
assert_eq!(seen[3].0, SourceKind::WindowsThemeFile);
|
||||
// 兜底档在 evidence 里如实标明「可能未启用」,调用方据此可降权。
|
||||
assert!(
|
||||
probe.sources()[3].evidence.contains("可能未启用"),
|
||||
"{}",
|
||||
probe.sources()[3].evidence
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_missing_returns_empty_with_reasons() {
|
||||
let probe = detect_with(&FakeIo::new(), &env(), Platform::Windows);
|
||||
assert!(probe.is_empty(), "全部失败要返回空列表而不是错误");
|
||||
let notes = probe.notes().join("\n");
|
||||
assert!(notes.contains("reg query"), "注册表失败要留原因:{notes}");
|
||||
assert!(
|
||||
notes.contains("CurrentTheme"),
|
||||
"当前主题失败要留原因:{notes}"
|
||||
);
|
||||
assert!(
|
||||
notes.contains("TranscodedWallpaper"),
|
||||
"转码副本缺失要留原因:{notes}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_path_from_two_steps_is_deduped() {
|
||||
let transcoded = themes().join("TranscodedWallpaper");
|
||||
let registry_value = transcoded.display().to_string().to_ascii_uppercase();
|
||||
let io = FakeIo::new()
|
||||
.with_image(&transcoded)
|
||||
// 注册表指向同一个文件,大小写还不同(Windows 路径大小写不敏感)。
|
||||
.with_command(
|
||||
DESKTOP_QUERY,
|
||||
&format!(" WallPaper REG_SZ {registry_value}\r\n"),
|
||||
);
|
||||
let probe = detect_with(&io, &env(), Platform::Windows);
|
||||
assert_eq!(probe.len(), 1, "{:?}", probe.sources());
|
||||
assert_eq!(
|
||||
probe.best().expect("有候选").kind,
|
||||
SourceKind::WindowsTranscoded
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Windows 侧的纯解析函数:`.theme` 的 `Wallpaper=` 行与 `reg query` 输出的取值。
|
||||
//!
|
||||
//! 单独一个文件只为守住「单文件 ≤400 行」(`guards.yml` 的 file-size 闸):这里没有任何
|
||||
//! IO,全部是纯函数,跨平台编译,夹具用例覆盖在文件尾部。
|
||||
|
||||
/// 取 `.theme` 文本里所有 `Wallpaper=` 行的值(键名大小写不敏感,值去首尾空白)。
|
||||
///
|
||||
/// `.theme` 是 INI:`[Control Panel\Desktop]` 段下的 `Wallpaper=...`。`Wallpaper` 这个键名
|
||||
/// 本身不随系统语言变(Windows 只本地化少数键),所以直接按 ASCII 比对。
|
||||
pub(super) fn parse_theme_wallpaper(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
if !key.trim().eq_ignore_ascii_case("wallpaper") {
|
||||
return None;
|
||||
}
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 取 `reg query <键> /v <值名>` 输出里某个值的原文。
|
||||
///
|
||||
/// 形如 ` WallPaper REG_SZ C:\Users\me\Pictures\a.jpg`;值里的空格要保留,所以
|
||||
/// 「值名 + 类型」之后的部分整段取原文(不能 `split_whitespace` 再拼,会吃掉连续空格)。
|
||||
/// 值名为 `WallPaper` 时是壁纸路径,`CurrentTheme` 时是 `.theme` 文件路径。
|
||||
pub(super) fn parse_reg_sz_value(text: &str, value_name: &str) -> Option<String> {
|
||||
text.lines().find_map(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
let name_end = trimmed.find(char::is_whitespace)?;
|
||||
if !trimmed[..name_end].eq_ignore_ascii_case(value_name) {
|
||||
return None;
|
||||
}
|
||||
let after_name = trimmed[name_end..].trim_start();
|
||||
let kind_end = after_name.find(char::is_whitespace)?;
|
||||
let value = after_name[kind_end..].trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{CURRENT_THEME_VALUE, WALLPAPER_VALUE};
|
||||
use super::*;
|
||||
|
||||
const CUSTOM_THEME: &str = r"C:\Users\u\AppData\Local\Microsoft\Windows\Themes\Custom.theme";
|
||||
|
||||
#[test]
|
||||
fn theme_text_yields_only_wallpaper_values() {
|
||||
let text = "[Control Panel\\Desktop]\r\n\
|
||||
Wallpaper=C:\\Users\\u\\a.jpg\r\n\
|
||||
wallpaper = %SystemRoot%\\web\\wallpaper\\b.jpg\r\n\
|
||||
[Slideshow]\r\n\
|
||||
ImagesRootPath=C:\\Users\\u\\Pictures\r\n\
|
||||
Wallpaper=\r\n";
|
||||
assert_eq!(
|
||||
parse_theme_wallpaper(text),
|
||||
vec![
|
||||
r"C:\Users\u\a.jpg".to_owned(),
|
||||
r"%SystemRoot%\web\wallpaper\b.jpg".to_owned(),
|
||||
]
|
||||
);
|
||||
assert!(parse_theme_wallpaper("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reg_output_parses_value_and_keeps_spaces() {
|
||||
assert_eq!(
|
||||
parse_reg_sz_value(
|
||||
"HKEY_CURRENT_USER\\Control Panel\\Desktop\r\n \
|
||||
WallPaper REG_SZ C:\\Users\\Jak e\\Desktop Pictures\\a.jpg\r\n",
|
||||
WALLPAPER_VALUE
|
||||
),
|
||||
Some(r"C:\Users\Jak e\Desktop Pictures\a.jpg".to_owned())
|
||||
);
|
||||
// 纯色壁纸 = 空值。
|
||||
assert_eq!(
|
||||
parse_reg_sz_value(" WallPaper REG_SZ \r\n", WALLPAPER_VALUE),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_reg_sz_value("", WALLPAPER_VALUE), None);
|
||||
assert_eq!(
|
||||
parse_reg_sz_value(" TileWallpaper REG_SZ 0\r\n", WALLPAPER_VALUE),
|
||||
None
|
||||
);
|
||||
// 当前主题:值名换成 CurrentTheme,取到的是 `.theme` 的绝对路径。
|
||||
assert_eq!(
|
||||
parse_reg_sz_value(
|
||||
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
|
||||
CURRENT_THEME_VALUE
|
||||
),
|
||||
Some(CUSTOM_THEME.to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_reg_sz_value(" CurrentTheme REG_SZ \r\n", CURRENT_THEME_VALUE),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user