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:
AskaEth committed 2026-11-02 11:55:59 +08:00
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
}
}