feat(app-iced): M79 导航家族 —— ListItem / Tabs / NavigationDrawer / AppBar
四件组件 + gallery 状态矩阵 + 渲染层几何断言,全部只落在 `components/navigation/**` 与 `gallery/navigation.rs`(不改 mod.rs / interaction / layout)。 - ListItem(12b §4):一行/两行/三行 44/60/76(A1 56/72/88,登记偏离)、leading/trailing 槽位、 hover/focus/pressed 只叠状态层(内容色不变)、禁用是修饰器且内容角色取 A1 的 `OnSurface`@38%。 - Tabs(12b §6):Primary 3px 指示条(用 `Stack` 分层让 Fill 条拿到「该 tab 的宽」)、 Secondary 1px `SurfaceVariant` 分隔线、带图标时容器 64、横向滚动;选中色按变体分(Primary / OnSurface)。 - NavigationDrawer(15 §21 + D33):**同一个容器两个宽度** —— rail 88 ⇄ drawer 240; 药丸从容器宽派生(56×32 / 216×56,左右各缩 16 / 12,A1 `ItemPadding`); 分组标题 TitleSmall、组间 1px OutlineVariant;drawer 项高 56(G24)。 - AppBar small(12b §16):页头 56(A1 64,登记偏离)、TitleLarge emphasized + LabelMedium 副标题、 leading/trailing 槽位、滚动只换 `SurfaceContainer` + L2(A1 无 OnScroll…Height)。 - gallery:`ULL_NAV_PART=list|tabs|drawer|appbar|all` 分块出图;四块矩阵(ListItem 三行高 × 三态、 Tabs 三个选中位置 + 五态、rail/drawer 同尺度并排、AppBar 静息/滚动)。 - `navigation/probe.rs`:读真截图量像素的 4 条断言(行高 44/60/76、禁用 OnSurface@38%、 指示条宽 = 标签墨迹 + 2×16、rail 88 / drawer 240 与药丸内缩 16/12、AppBar 56), 已用两次变异(行高改 56/72/88;drawer 内缩改 16)验证能被打红。 门禁:fmt ✓ · clippy -p unlucky-app-iced --all-targets -D warnings ✓ · test -p unlucky-app-iced 195 passed ✓
This commit is contained in:
1 parent
22636668d0
commit
81e34d77da
14 files changed
+3149
-39
No files matched your search
@@ -0,0 +1,188 @@
|
||||
//! `probe` 的读图器(`probe.rs` 拆出来的:单文件 ≤400 行,AGENTS.md §3-1)。
|
||||
//!
|
||||
//! 只做三件事:读 PNG、找同色的连通块(`BBox`)、量一条横带里某颜色的墨迹范围。
|
||||
//! 判据全在 `probe.rs`。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
/// 字体族(与 `gallery::probe` 同一套)。
|
||||
pub(super) const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 颜色比对容差。
|
||||
///
|
||||
/// **3 而不是 10**:本家族要区分的几个容器色本身很接近(light 下
|
||||
/// `SurfaceContainerLow (235,245,255)` vs `SurfaceContainer (227,239,251)` 只差 8/6/4),
|
||||
/// 容差 10 会把「取景板」和「滚动后的 AppBar」连成一块,量出来的高度就不对了。
|
||||
/// 实色区域是精确值,3 足够吸收抗锯齿的取整误差。
|
||||
pub(super) const TOLERANCE: u8 = 3;
|
||||
|
||||
pub(super) type Rgb = [u8; 3];
|
||||
|
||||
pub(super) fn ui(flavor: SchemeFlavor) -> Ui {
|
||||
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
pub(super) fn rgb(color: iced::Color) -> Rgb {
|
||||
[
|
||||
(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: Rgb, expected: Rgb) -> bool {
|
||||
pixel
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(a, b)| a.abs_diff(b) <= TOLERANCE)
|
||||
}
|
||||
|
||||
/// 一个外接框(闭区间)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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 width(&self) -> usize {
|
||||
self.right - self.left + 1
|
||||
}
|
||||
|
||||
pub(super) fn height(&self) -> usize {
|
||||
self.bottom - self.top + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// 一张图的像素访问器。
|
||||
pub(super) struct Shot {
|
||||
pub(super) width: usize,
|
||||
pub(super) height: usize,
|
||||
pixels: Vec<Rgb>,
|
||||
}
|
||||
|
||||
impl Shot {
|
||||
pub(super) fn load(path: &Path) -> Self {
|
||||
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读不动 {path:?}:{e}"));
|
||||
let image = image::load_from_memory(&bytes)
|
||||
.unwrap_or_else(|e| panic!("解 PNG 失败 {path:?}:{e}"))
|
||||
.to_rgb8();
|
||||
assert_eq!(
|
||||
(image.width() as usize, image.height() as usize),
|
||||
(1180, 760),
|
||||
"验收图必须是 D30 的 1180×760 @100% DPI"
|
||||
);
|
||||
Self {
|
||||
width: image.width() as usize,
|
||||
height: image.height() as usize,
|
||||
pixels: image.pixels().map(|p| p.0).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn at(&self, x: usize, y: usize) -> Rgb {
|
||||
self.pixels[y * self.width + x]
|
||||
}
|
||||
|
||||
/// 某种颜色的**全部**连通块(面积 ≥ 24;文字笔画太碎的块直接丢掉)。
|
||||
pub(super) fn blocks(&self, color: Rgb) -> Vec<BBox> {
|
||||
let mut seen = vec![false; self.width * self.height];
|
||||
let mut found = 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 {
|
||||
left: x,
|
||||
right: x,
|
||||
top: y,
|
||||
bottom: y,
|
||||
};
|
||||
while let Some((cx, cy)) = stack.pop() {
|
||||
bbox.left = bbox.left.min(cx);
|
||||
bbox.right = bbox.right.max(cx);
|
||||
bbox.top = bbox.top.min(cy);
|
||||
bbox.bottom = bbox.bottom.max(cy);
|
||||
for (nx, ny) in [
|
||||
(cx + 1, cy),
|
||||
(cx.saturating_sub(1), cy),
|
||||
(cx, cy + 1),
|
||||
(cx, cy.saturating_sub(1)),
|
||||
] {
|
||||
if nx < self.width
|
||||
&& ny < self.height
|
||||
&& !seen[ny * self.width + nx]
|
||||
&& matches(self.at(nx, ny), color)
|
||||
{
|
||||
seen[ny * self.width + nx] = true;
|
||||
stack.push((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
if bbox.width() * bbox.height() >= 24 {
|
||||
found.push(bbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// 矩形内**最深**的像素(禁用的核心笔画就是精确的混合色,用它判角色最稳)。
|
||||
pub(super) fn darkest(&self, bbox: &BBox) -> Option<Rgb> {
|
||||
let mut best: Option<(u32, Rgb)> = None;
|
||||
for y in bbox.top..=bbox.bottom.min(self.height - 1) {
|
||||
for x in bbox.left..=bbox.right.min(self.width - 1) {
|
||||
let pixel = self.at(x, y);
|
||||
let sum: u32 = pixel.iter().map(|c| u32::from(*c)).sum();
|
||||
if best.is_none_or(|(current, _)| sum < current) {
|
||||
best = Some((sum, pixel));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, pixel)| pixel)
|
||||
}
|
||||
|
||||
/// 矩形内「与底色不同」的像素的左右范围(量文本墨迹用)。
|
||||
///
|
||||
/// 为什么不按精确文字色找:小字号抗锯齿后几乎没有像素落在**精确**色上(容差 3 时实测
|
||||
/// 「Logs」只剩 12px)。改成「与底色不同即墨迹」,既与字色无关,也不会被状态层带偏。
|
||||
pub(super) fn band_ink(
|
||||
&self,
|
||||
background: Rgb,
|
||||
x0: usize,
|
||||
x1: usize,
|
||||
y0: usize,
|
||||
y1: usize,
|
||||
) -> Option<(usize, usize)> {
|
||||
let mut span: Option<(usize, usize)> = None;
|
||||
for y in y0..=y1.min(self.height - 1) {
|
||||
for x in x0..=x1.min(self.width - 1) {
|
||||
let pixel = self.at(x, y);
|
||||
let differs = pixel
|
||||
.iter()
|
||||
.zip(background)
|
||||
.any(|(a, b)| a.abs_diff(b) > 12);
|
||||
if differs {
|
||||
span = Some(match span {
|
||||
Some((l, r)) => (l.min(x), r.max(x)),
|
||||
None => (x, x),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
span
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user